Files
aza/AzA march 2026/admin_routes.py

1159 lines
44 KiB
Python
Raw Normal View History

2026-04-16 13:32:32 +02:00
# 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
2026-03-25 22:03:39 +01:00
from __future__ import annotations
2026-04-16 13:32:32 +02:00
import calendar
2026-05-20 00:09:28 +02:00
import csv
import io
2026-04-16 13:32:32 +02:00
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
2026-05-20 00:09:28 +02:00
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse, Response
2026-04-16 13:32:32 +02:00
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()
2026-03-25 22:03:39 +01:00
2026-04-16 13:32:32 +02:00
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]
2026-03-25 22:03:39 +01:00
2026-04-16 13:32:32 +02:00
processed_count = con.execute(
"SELECT COUNT(*) FROM processed_events"
).fetchone()[0]
db_info["processed_events_count"] = processed_count
2026-03-25 22:03:39 +01:00
2026-04-16 13:32:32 +02:00
con.close()
except Exception as e:
db_info["error"] = str(e)
2026-03-25 22:03:39 +01:00
2026-04-16 13:32:32 +02:00
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():
2026-03-25 22:03:39 +01:00
try:
2026-04-16 13:32:32 +02:00
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:
2026-03-25 22:03:39 +01:00
try:
2026-04-16 13:32:32 +02:00
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():
2026-03-25 22:03:39 +01:00
try:
2026-04-16 13:32:32 +02:00
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:
2026-03-25 22:03:39 +01:00
try:
2026-04-16 13:32:32 +02:00
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]}
2026-03-25 22:03:39 +01:00
for r in rows
]
2026-04-16 13:32:32 +02:00
}
except Exception as exc:
return {"error": str(exc), "customers": []}
2026-05-20 00:09:28 +02:00
# ---------------------------------------------------------------------------
# KI-Budget Phase 1 (Admin, USD sichtbar)
# ---------------------------------------------------------------------------
@router.get("/ai_budget_overview")
def ai_budget_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]:
"""Übersicht aktiver Lizenzen inkl. geschätztem KI-USD-Verbrauch pro Stripe-Periode."""
from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "licenses": []}
try:
import backend_main as _bm
_bm.ensure_license_schema(db_path)
except Exception as exc:
return {"status": "error", "error": str(exc), "licenses": []}
out: List[Dict[str, Any]] = []
try:
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
q = (
"SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, "
"current_period_start, current_period_end "
"FROM licenses"
)
params: List[Any] = []
if status and status.strip():
q += " WHERE status = ?"
params.append(status.strip())
q += " ORDER BY updated_at DESC LIMIT 200"
rows = con.execute(q, params).fetchall()
for r in rows:
lic = LicenseBudgetRow(
subscription_id=str(r[0] or ""),
customer_email=str(r[2] or ""),
customer_id=str(r[1]) if r[1] else None,
practice_id=str(r[3]).strip() if r[3] else None,
lookup_key=str(r[4]).strip() if r[4] else None,
status=str(r[5] or ""),
period_start=int(r[6]) if r[6] is not None else None,
period_end=int(r[7]) if r[7] is not None else None,
)
snap = compute_budget_snapshot(con, lic)
ev = con.execute(
"""
SELECT COUNT(*), MAX(created_at)
FROM ai_usage_events
WHERE subscription_id = ?
AND period_start = ? AND period_end = ?
""",
(lic.subscription_id, snap["period_start"], snap["period_end"]),
).fetchone()
row_dict = dict(snap)
row_dict["customer_email"] = lic.customer_email
row_dict["lookup_key"] = lic.lookup_key or ""
row_dict["license_status"] = lic.status
row_dict["event_count"] = int(ev[0] or 0)
row_dict["last_event_ts"] = int(ev[1] or 0)
if row_dict["last_event_ts"]:
row_dict["last_event_human"] = datetime.fromtimestamp(
row_dict["last_event_ts"], tz=timezone.utc
).strftime("%Y-%m-%d %H:%M UTC")
out.append(row_dict)
return {"status": "ok", "licenses": out}
except Exception as exc:
return {"status": "error", "error": str(exc), "licenses": []}
@router.get("/ai_budget_detail")
def ai_budget_detail(email: str = Query(..., min_length=3)) -> Dict[str, Any]:
from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database"}
import backend_main as _bm
_bm.ensure_license_schema(db_path)
em = email.strip().lower()
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
row = con.execute(
"""
SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status,
current_period_start, current_period_end
FROM licenses
WHERE lower(customer_email) = ?
ORDER BY updated_at DESC
LIMIT 1
""",
(em,),
).fetchone()
if not row:
return {"status": "not_found", "email": em}
lic = LicenseBudgetRow(
subscription_id=str(row[0] or ""),
customer_email=str(row[2] or ""),
customer_id=str(row[1]) if row[1] else None,
practice_id=str(row[3]).strip() if row[3] else None,
lookup_key=str(row[4]).strip() if row[4] else None,
status=str(row[5] or ""),
period_start=int(row[6]) if row[6] is not None else None,
period_end=int(row[7]) if row[7] is not None else None,
)
snap = compute_budget_snapshot(con, lic)
by_op = con.execute(
"""
SELECT operation_type, COALESCE(SUM(estimated_cost_usd), 0)
FROM ai_usage_events
WHERE subscription_id = ? AND period_start = ? AND period_end = ? AND status = 'success'
GROUP BY operation_type
""",
(lic.subscription_id, snap["period_start"], snap["period_end"]),
).fetchall()
by_model = con.execute(
"""
SELECT model, COALESCE(SUM(estimated_cost_usd), 0)
FROM ai_usage_events
WHERE subscription_id = ? AND period_start = ? AND period_end = ? AND status = 'success'
GROUP BY model
""",
(lic.subscription_id, snap["period_start"], snap["period_end"]),
).fetchall()
recent = con.execute(
"""
SELECT created_at, operation_type, model, estimated_cost_usd, status, error_code, request_id
FROM ai_usage_events
WHERE subscription_id = ?
ORDER BY created_at DESC
LIMIT 20
""",
(lic.subscription_id,),
).fetchall()
blocked = con.execute(
"""
SELECT created_at, operation_type, error_code, request_id
FROM ai_usage_events
WHERE subscription_id = ? AND status = 'blocked'
ORDER BY created_at DESC
LIMIT 20
""",
(lic.subscription_id,),
).fetchall()
return {
"status": "ok",
"snapshot": snap,
"usage_by_operation": [{"operation": a[0], "usd": round(float(a[1] or 0), 6)} for a in by_op],
"usage_by_model": [{"model": a[0], "usd": round(float(a[1] or 0), 6)} for a in by_model],
"recent_events": [
{
"created_at": a[0],
"operation_type": a[1],
"model": a[2],
"usd": float(a[3] or 0),
"status": a[4],
"error_code": a[5],
"request_id": a[6],
}
for a in recent
],
"blocked_events": [
{"created_at": b[0], "operation_type": b[1], "error_code": b[2], "request_id": b[3]}
for b in blocked
],
}
@router.get("/ai_budget_export.csv")
def ai_budget_export_csv(status: Optional[str] = Query("active")) -> Response:
import backend_main as _bm
from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema
db_path = _stripe_db_path()
if not db_path.exists():
return Response("no database\n", media_type="text/plain", status_code=404)
_bm.ensure_license_schema(db_path)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow([
"subscription_id", "customer_email", "license_status", "lookup_key",
"period_start_utc", "period_end_utc", "budget_usd", "used_usd", "remaining_usd",
"available_percent", "event_count",
])
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
q = (
"SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, "
"current_period_start, current_period_end FROM licenses"
)
params: List[Any] = []
if status and status.strip():
q += " WHERE status = ?"
params.append(status.strip())
q += " ORDER BY updated_at DESC LIMIT 500"
for r in con.execute(q, params).fetchall():
lic = LicenseBudgetRow(
subscription_id=str(r[0] or ""),
customer_email=str(r[2] or ""),
customer_id=str(r[1]) if r[1] else None,
practice_id=str(r[3]).strip() if r[3] else None,
lookup_key=str(r[4]).strip() if r[4] else None,
status=str(r[5] or ""),
period_start=int(r[6]) if r[6] is not None else None,
period_end=int(r[7]) if r[7] is not None else None,
)
snap = compute_budget_snapshot(con, lic)
evc = con.execute(
"""
SELECT COUNT(*) FROM ai_usage_events
WHERE subscription_id = ? AND period_start = ? AND period_end = ?
""",
(lic.subscription_id, snap["period_start"], snap["period_end"]),
).fetchone()
ps_h = datetime.fromtimestamp(int(snap["period_start"]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
pe_h = datetime.fromtimestamp(int(snap["period_end"]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
w.writerow([
lic.subscription_id,
lic.customer_email,
lic.status,
lic.lookup_key or "",
ps_h,
pe_h,
snap.get("budget_usd"),
snap.get("used_usd"),
snap.get("remaining_usd"),
snap.get("available_percent"),
int(evc[0] or 0),
])
data = buf.getvalue()
return Response(
content=data.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="ai_budget_export.csv"'},
)
# ---------------------------------------------------------------------------
# Stripe: Abrechnungsperioden (read-only Subscription.retrieve, nur Perioden-Spalten)
# ---------------------------------------------------------------------------
@router.post("/stripe_sync_periods")
def stripe_sync_periods() -> Dict[str, Any]:
"""Aktive Lizenzen: current_period_start/end aus Stripe füllen. Kein Status-/Kunden-Write."""
from stripe_routes import sync_active_license_periods_from_stripe_only
return sync_active_license_periods_from_stripe_only()
@router.post("/woocommerce_sync_periods")
def woocommerce_sync_periods() -> Dict[str, Any]:
"""Aktive wc_sub_*-Lizenzen: Abrechnungsperioden read-only aus WooCommerce REST."""
from aza_wc_period_sync import sync_active_license_periods_from_woocommerce_only
out = sync_active_license_periods_from_woocommerce_only()
if isinstance(out, dict):
code = out.get("error_code")
if code in ("WOO_CREDENTIALS_MISSING", "DB_MISSING", "REQUESTS_MISSING"):
return JSONResponse(status_code=503, content=out) # type: ignore[return-value]
return out
2026-05-23 21:31:34 +02:00
@router.get("/ai_credit_overview")
def ai_credit_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]:
"""KI-Zusatzguthaben-Übersicht (Admin, keine Secrets)."""
from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema, monthly_budget_usd_for_license
from aza_ai_credit import admin_credit_overview_row, ensure_ai_credit_schema
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "licenses": []}
import backend_main as _bm
_bm.ensure_license_schema(db_path)
out: List[Dict[str, Any]] = []
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
ensure_ai_credit_schema(con)
q = (
"SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, "
"current_period_start, current_period_end FROM licenses"
)
params: List[Any] = []
if status and status.strip():
q += " WHERE status = ?"
params.append(status.strip())
q += " ORDER BY updated_at DESC LIMIT 200"
rows = con.execute(q, params).fetchall()
for r in rows:
lic = LicenseBudgetRow(
subscription_id=str(r[0] or ""),
customer_email=str(r[2] or ""),
customer_id=str(r[1]) if r[1] else None,
practice_id=str(r[3]).strip() if r[3] else None,
lookup_key=str(r[4]).strip() if r[4] else None,
status=str(r[5] or ""),
period_start=int(r[6]) if r[6] is not None else None,
period_end=int(r[7]) if r[7] is not None else None,
)
if not (lic.practice_id or "").strip():
continue
snap = compute_budget_snapshot(con, lic)
budget = monthly_budget_usd_for_license(lic)
out.append(
admin_credit_overview_row(
con,
practice_id=lic.practice_id or "",
subscription_id=lic.subscription_id,
monthly_budget=budget,
snap=snap,
)
)
return {"status": "ok", "licenses": out}
@router.get("/ai_credit_ledger")
def ai_credit_ledger(
practice_id: str = Query(..., min_length=3),
limit: int = Query(100, ge=1, le=500),
) -> Dict[str, Any]:
from aza_ai_credit import ensure_ai_credit_schema, list_ledger_for_practice
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "entries": []}
import backend_main as _bm
_bm.ensure_license_schema(db_path)
pid = practice_id.strip()
with sqlite3.connect(str(db_path)) as con:
ensure_ai_credit_schema(con)
entries = list_ledger_for_practice(con, pid, limit=limit)
return {"status": "ok", "practice_id": pid, "entries": entries}
@router.get("/ai_credit_overview")
def ai_credit_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]:
"""KI-Zusatzguthaben-Übersicht (Admin, keine Secrets)."""
from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema, monthly_budget_usd_for_license
from aza_ai_credit import admin_credit_overview_row, ensure_ai_credit_schema
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "licenses": []}
import backend_main as _bm
_bm.ensure_license_schema(db_path)
out: List[Dict[str, Any]] = []
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
ensure_ai_credit_schema(con)
q = (
"SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, "
"current_period_start, current_period_end FROM licenses"
)
params: List[Any] = []
if status and status.strip():
q += " WHERE status = ?"
params.append(status.strip())
q += " ORDER BY updated_at DESC LIMIT 200"
rows = con.execute(q, params).fetchall()
for r in rows:
lic = LicenseBudgetRow(
subscription_id=str(r[0] or ""),
customer_email=str(r[2] or ""),
customer_id=str(r[1]) if r[1] else None,
practice_id=str(r[3]).strip() if r[3] else None,
lookup_key=str(r[4]).strip() if r[4] else None,
status=str(r[5] or ""),
period_start=int(r[6]) if r[6] is not None else None,
period_end=int(r[7]) if r[7] is not None else None,
)
if not (lic.practice_id or "").strip():
continue
snap = compute_budget_snapshot(con, lic)
budget = monthly_budget_usd_for_license(lic)
out.append(
admin_credit_overview_row(
con,
practice_id=lic.practice_id or "",
subscription_id=lic.subscription_id,
monthly_budget=budget,
snap=snap,
)
)
return {"status": "ok", "licenses": out}
@router.get("/ai_credit_ledger")
def ai_credit_ledger(
practice_id: str = Query(..., min_length=3),
limit: int = Query(100, ge=1, le=500),
) -> Dict[str, Any]:
from aza_ai_credit import ensure_ai_credit_schema, list_ledger_for_practice
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "entries": []}
import backend_main as _bm
_bm.ensure_license_schema(db_path)
pid = practice_id.strip()
with sqlite3.connect(str(db_path)) as con:
ensure_ai_credit_schema(con)
entries = list_ledger_for_practice(con, pid, limit=limit)
return {"status": "ok", "practice_id": pid, "entries": entries}