205 lines
6.8 KiB
Python
205 lines
6.8 KiB
Python
|
|
"""
|
||
|
|
AZA / MedWork - Admin Routes (FastAPI)
|
||
|
|
|
||
|
|
Separiert die Admin-Endpunkte aus license_server.py:
|
||
|
|
- /admin/set_plan
|
||
|
|
- /admin/revoke_token
|
||
|
|
- /admin/set_status
|
||
|
|
- /admin/audit/list
|
||
|
|
|
||
|
|
Alle Endpunkte sind per AZA_ADMIN_KEY geschützt.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Optional, Callable, Any
|
||
|
|
|
||
|
|
from fastapi import APIRouter, HTTPException
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
|
||
|
|
# -----------------------------
|
||
|
|
# Models
|
||
|
|
# -----------------------------
|
||
|
|
class AdminSetPlanRequest(BaseModel):
|
||
|
|
admin_key: str = Field(..., min_length=1, max_length=512)
|
||
|
|
email: str = Field(..., min_length=3, max_length=255)
|
||
|
|
plan: str = Field(..., min_length=1, max_length=64)
|
||
|
|
|
||
|
|
|
||
|
|
class AdminSetPlanResponse(BaseModel):
|
||
|
|
ok: bool
|
||
|
|
email: str
|
||
|
|
plan: str
|
||
|
|
message: str
|
||
|
|
|
||
|
|
|
||
|
|
class AdminRevokeTokenRequest(BaseModel):
|
||
|
|
admin_key: str = Field(..., min_length=1, max_length=512)
|
||
|
|
token: str = Field(..., min_length=1, max_length=4096)
|
||
|
|
|
||
|
|
|
||
|
|
class AdminRevokeTokenResponse(BaseModel):
|
||
|
|
ok: bool
|
||
|
|
token: str
|
||
|
|
message: str
|
||
|
|
|
||
|
|
|
||
|
|
class AdminSetStatusRequest(BaseModel):
|
||
|
|
admin_key: str = Field(..., min_length=1, max_length=512)
|
||
|
|
email: str = Field(..., min_length=3, max_length=255)
|
||
|
|
status: str = Field(..., min_length=1, max_length=64)
|
||
|
|
|
||
|
|
|
||
|
|
class AdminSetStatusResponse(BaseModel):
|
||
|
|
ok: bool
|
||
|
|
email: str
|
||
|
|
status: str
|
||
|
|
message: str
|
||
|
|
|
||
|
|
|
||
|
|
class AdminAuditListRequest(BaseModel):
|
||
|
|
admin_key: str = Field(..., min_length=1, max_length=512)
|
||
|
|
limit: int = Field(50, ge=1, le=500)
|
||
|
|
|
||
|
|
|
||
|
|
class AdminAuditItem(BaseModel):
|
||
|
|
id: int
|
||
|
|
action: str
|
||
|
|
email: Optional[str] = None
|
||
|
|
token: Optional[str] = None
|
||
|
|
old_value: Optional[str] = None
|
||
|
|
new_value: Optional[str] = None
|
||
|
|
created_at: str
|
||
|
|
|
||
|
|
|
||
|
|
class AdminAuditListResponse(BaseModel):
|
||
|
|
ok: bool
|
||
|
|
items: list[AdminAuditItem]
|
||
|
|
|
||
|
|
|
||
|
|
# -----------------------------
|
||
|
|
# Router factory
|
||
|
|
# -----------------------------
|
||
|
|
def build_admin_router(
|
||
|
|
*,
|
||
|
|
get_db: Callable[[], Any],
|
||
|
|
admin_key: str,
|
||
|
|
log_admin_action: Callable[..., None],
|
||
|
|
) -> APIRouter:
|
||
|
|
"""
|
||
|
|
get_db: function returning sqlite3.Connection (row_factory set)
|
||
|
|
admin_key: AZA_ADMIN_KEY (string)
|
||
|
|
log_admin_action: function(conn, action, email?, token?, old_value?, new_value?)
|
||
|
|
"""
|
||
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||
|
|
|
||
|
|
def _require_admin_key(provided: str) -> None:
|
||
|
|
if not admin_key:
|
||
|
|
raise HTTPException(status_code=503, detail="Admin not configured (AZA_ADMIN_KEY missing)")
|
||
|
|
if (provided or "").strip() != admin_key:
|
||
|
|
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||
|
|
|
||
|
|
@router.post("/set_plan", response_model=AdminSetPlanResponse)
|
||
|
|
def admin_set_plan(req: AdminSetPlanRequest):
|
||
|
|
_require_admin_key(req.admin_key)
|
||
|
|
|
||
|
|
email = req.email.strip().lower()
|
||
|
|
plan = req.plan.strip().lower()
|
||
|
|
if not email or "@" not in email:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid email")
|
||
|
|
if not plan:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid plan")
|
||
|
|
|
||
|
|
conn = get_db()
|
||
|
|
try:
|
||
|
|
row = conn.execute("SELECT id, plan FROM users WHERE email = ?", (email,)).fetchone()
|
||
|
|
if not row:
|
||
|
|
raise HTTPException(status_code=404, detail="User not found")
|
||
|
|
old_plan = str(row["plan"]) if row["plan"] else ""
|
||
|
|
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||
|
|
log_admin_action(conn, action="set_plan", email=email, old_value=old_plan, new_value=plan)
|
||
|
|
conn.commit()
|
||
|
|
return AdminSetPlanResponse(ok=True, email=email, plan=plan, message="Plan updated")
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
@router.post("/revoke_token", response_model=AdminRevokeTokenResponse)
|
||
|
|
def admin_revoke_token(req: AdminRevokeTokenRequest):
|
||
|
|
_require_admin_key(req.admin_key)
|
||
|
|
|
||
|
|
token = req.token.strip()
|
||
|
|
if not token:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid token")
|
||
|
|
|
||
|
|
conn = get_db()
|
||
|
|
try:
|
||
|
|
row = conn.execute("SELECT token FROM tokens WHERE token = ?", (token,)).fetchone()
|
||
|
|
if not row:
|
||
|
|
raise HTTPException(status_code=404, detail="Token not found")
|
||
|
|
conn.execute("UPDATE tokens SET revoked = 1 WHERE token = ?", (token,))
|
||
|
|
log_admin_action(conn, action="revoke_token", token=token, old_value="active", new_value="revoked")
|
||
|
|
conn.commit()
|
||
|
|
return AdminRevokeTokenResponse(ok=True, token=token, message="Token revoked")
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
@router.post("/set_status", response_model=AdminSetStatusResponse)
|
||
|
|
def admin_set_status(req: AdminSetStatusRequest):
|
||
|
|
_require_admin_key(req.admin_key)
|
||
|
|
|
||
|
|
email = req.email.strip().lower()
|
||
|
|
status = req.status.strip().lower()
|
||
|
|
if not email or "@" not in email:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid email")
|
||
|
|
if status not in ("active", "suspended", "cancelled"):
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid status (use active|suspended|cancelled)")
|
||
|
|
|
||
|
|
conn = get_db()
|
||
|
|
try:
|
||
|
|
row = conn.execute("SELECT id, status FROM users WHERE email = ?", (email,)).fetchone()
|
||
|
|
if not row:
|
||
|
|
raise HTTPException(status_code=404, detail="User not found")
|
||
|
|
old_status = str(row["status"]) if row["status"] else ""
|
||
|
|
conn.execute("UPDATE users SET status = ? WHERE email = ?", (status, email))
|
||
|
|
log_admin_action(conn, action="set_status", email=email, old_value=old_status, new_value=status)
|
||
|
|
conn.commit()
|
||
|
|
return AdminSetStatusResponse(ok=True, email=email, status=status, message="Status updated")
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
@router.post("/audit/list", response_model=AdminAuditListResponse)
|
||
|
|
def admin_audit_list(req: AdminAuditListRequest):
|
||
|
|
_require_admin_key(req.admin_key)
|
||
|
|
|
||
|
|
limit = int(req.limit)
|
||
|
|
conn = get_db()
|
||
|
|
try:
|
||
|
|
rows = conn.execute(
|
||
|
|
"""
|
||
|
|
SELECT id, action, email, token, old_value, new_value, created_at
|
||
|
|
FROM admin_audit
|
||
|
|
ORDER BY id DESC
|
||
|
|
LIMIT ?
|
||
|
|
""",
|
||
|
|
(limit,),
|
||
|
|
).fetchall()
|
||
|
|
items = [
|
||
|
|
AdminAuditItem(
|
||
|
|
id=int(r["id"]),
|
||
|
|
action=str(r["action"]),
|
||
|
|
email=r["email"],
|
||
|
|
token=r["token"],
|
||
|
|
old_value=r["old_value"],
|
||
|
|
new_value=r["new_value"],
|
||
|
|
created_at=str(r["created_at"]),
|
||
|
|
)
|
||
|
|
for r in rows
|
||
|
|
]
|
||
|
|
return AdminAuditListResponse(ok=True, items=items)
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
return router
|
||
|
|
|