| """
|
| app/admin/router.py
|
| βββββββββββββββββββ
|
| Secure FastAPI router for the Admin Panel.
|
|
|
| Security design:
|
| β’ All /admin/* routes require HTTP Basic Auth.
|
| β’ Credentials are compared using secrets.compare_digest() to prevent
|
| timing-based attacks β NOT a simple == comparison.
|
| β’ Wrong credentials always return 401 with WWW-Authenticate header.
|
| β’ Admin routes are completely isolated from the main app and Gradio UI.
|
|
|
| Endpoints:
|
| GET /admin β Serves the premium admin.html SPA
|
| GET /api/admin/sessions β Returns all session summaries (JSON)
|
| GET /api/admin/sessions/{session_id} β Returns full session transcript
|
| DELETE /api/admin/sessions/{session_id} β Deletes a session
|
| """
|
|
|
| import secrets
|
| import logging
|
| import asyncio
|
| import time
|
| import random
|
| import smtplib
|
| import socket
|
| import json as _json
|
| import urllib.parse
|
| from email.mime.text import MIMEText
|
| from email.mime.multipart import MIMEMultipart
|
| from pathlib import Path
|
| from typing import Dict, Optional
|
|
|
| import pandas as pd
|
| import io
|
| from fastapi import APIRouter, Depends, HTTPException, status
|
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
| from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
| from pydantic import BaseModel
|
|
|
| from app.services.session_store import get_all_sessions, get_session, delete_session, _list_json_files, _read_json
|
| from app.core.config import get_settings
|
|
|
| logger = logging.getLogger(__name__)
|
| settings = get_settings()
|
|
|
|
|
|
|
|
|
| security = HTTPBasic()
|
|
|
|
|
| admin_router = APIRouter(tags=["admin"])
|
|
|
|
|
|
|
| _otp_store: Dict[str, dict] = {}
|
| OTP_EXPIRY_SECONDS = 300
|
|
|
|
|
| _ADMIN_HTML_PATH = Path(__file__).parent / "templates" / "admin.html"
|
|
|
|
|
|
|
| def verify_admin(credentials: HTTPBasicCredentials = Depends(security)) -> str:
|
| """
|
| Verifies HTTP Basic credentials using constant-time comparison.
|
| Raises 401 for any mismatch. Returns the username on success.
|
| """
|
| correct_username = secrets.compare_digest(
|
| credentials.username.encode("utf-8"),
|
| settings.admin_user.encode("utf-8"),
|
| )
|
| correct_password = secrets.compare_digest(
|
| credentials.password.encode("utf-8"),
|
| settings.admin_pass.encode("utf-8"),
|
| )
|
| if not (correct_username and correct_password):
|
| logger.warning(
|
| "Admin auth failed for user: '%s'", credentials.username
|
| )
|
| raise HTTPException(
|
| status_code=status.HTTP_401_UNAUTHORIZED,
|
| detail="Invalid admin credentials.",
|
| )
|
| return credentials.username
|
|
|
|
|
|
|
|
|
|
|
| class LoginRequest(BaseModel):
|
| username: str
|
| password: str
|
|
|
| class OTPVerifyRequest(BaseModel):
|
| username: str
|
| password: str
|
| code: str
|
|
|
| @admin_router.post("/api/admin/request-otp", include_in_schema=False)
|
| async def request_otp(req: LoginRequest):
|
| """
|
| Verifies password and generates a 6-digit OTP.
|
| Sends the OTP to the configured admin email.
|
| """
|
| if not (secrets.compare_digest(req.username, settings.admin_user) and
|
| secrets.compare_digest(req.password, settings.admin_pass)):
|
| raise HTTPException(status_code=401, detail="Invalid credentials.")
|
|
|
|
|
| code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
| _otp_store[req.username] = {
|
| "code": code,
|
| "expires": time.time() + OTP_EXPIRY_SECONDS
|
| }
|
|
|
|
|
| email_sent = False
|
| if settings.smtp_user and settings.smtp_pass:
|
| try:
|
|
|
| await asyncio.to_thread(send_otp_email, settings.admin_email, code)
|
| email_sent = True
|
| logger.info("OTP email sent to %s", settings.admin_email)
|
| except Exception as e:
|
| logger.error("Failed to send OTP email: %s", e)
|
| else:
|
| logger.warning("SMTP credentials not configured. OTP will only be in logs.")
|
|
|
|
|
| print("\n" + "="*50)
|
| print(f" ADMIN OTP FOR {req.username.upper()}: {code}")
|
| print(f" SENDING TO: {settings.admin_email} ({'SUCCESS' if email_sent else 'FAILED/SKIPPED'})")
|
| print(" EXPIRES IN 5 MINUTES")
|
| print("="*50 + "\n")
|
|
|
| return {
|
| "message": "OTP sent successfully.",
|
| "email_status": "sent" if email_sent else "logged_to_console_only"
|
| }
|
|
|
|
|
| def send_otp_email(to_email: str, otp_code: str):
|
| """Sends a professional OTP email via SMTP. Run in thread."""
|
| msg = MIMEMultipart()
|
| msg['From'] = settings.smtp_user
|
| msg['To'] = to_email
|
| msg['Subject'] = f"Your Admin Access Code: {otp_code}"
|
|
|
| body = f"""
|
| <html>
|
| <body style="font-family: sans-serif; padding: 20px; color: #333;">
|
| <h2 style="color: #3b82f6;">Martechsol Admin Panel</h2>
|
| <p>Your one-time security code is:</p>
|
| <div style="font-size: 32px; font-weight: bold; letter-spacing: 5px; padding: 15px; background: #f3f4f6; border-radius: 8px; display: inline-block; margin: 10px 0;">
|
| {otp_code}
|
| </div>
|
| <p style="color: #666; font-size: 14px;">This code will expire in 5 minutes.</p>
|
| <hr style="border: 0; border-top: 1px solid #eee; margin-top: 20px;">
|
| <p style="font-size: 12px; color: #999;">If you did not request this code, please secure your account.</p>
|
| </body>
|
| </html>
|
| """
|
| msg.attach(MIMEText(body, 'html'))
|
|
|
|
|
| try:
|
| ais = socket.getaddrinfo(settings.smtp_server, settings.smtp_port, socket.AF_INET)
|
| target_ip = ais[0][4][0]
|
| logger.info("Resolved %s to IPv4: %s", settings.smtp_server, target_ip)
|
| except Exception as e:
|
| logger.error("DNS Resolution failed: %s", e)
|
| target_ip = settings.smtp_server
|
|
|
| logger.info("Attempting SMTP_SSL connection to %s:%d (via %s)",
|
| settings.smtp_server, settings.smtp_port, target_ip)
|
|
|
|
|
| with smtplib.SMTP_SSL(target_ip, settings.smtp_port, timeout=15) as server:
|
| server.login(settings.smtp_user, settings.smtp_pass)
|
| server.send_message(msg)
|
|
|
|
|
| @admin_router.post("/api/admin/verify-otp", include_in_schema=False)
|
| async def verify_otp(req: OTPVerifyRequest):
|
| """Verifies both the password and the OTP code."""
|
|
|
| if not (secrets.compare_digest(req.username, settings.admin_user) and
|
| secrets.compare_digest(req.password, settings.admin_pass)):
|
| raise HTTPException(status_code=401, detail="Invalid credentials.")
|
|
|
|
|
| stored = _otp_store.get(req.username)
|
| if not stored:
|
| raise HTTPException(status_code=400, detail="No OTP requested for this user.")
|
|
|
| if time.time() > stored["expires"]:
|
| del _otp_store[req.username]
|
| raise HTTPException(status_code=400, detail="OTP has expired. Please request a new one.")
|
|
|
| if not secrets.compare_digest(req.code, stored["code"]):
|
| raise HTTPException(status_code=401, detail="Invalid OTP code.")
|
|
|
|
|
| del _otp_store[req.username]
|
| return {"message": "OTP verified."}
|
|
|
|
|
|
|
|
|
| @admin_router.get("/admin", response_class=HTMLResponse, include_in_schema=False)
|
| async def admin_panel():
|
| """Serves the admin panel SPA."""
|
| try:
|
| html_content = _ADMIN_HTML_PATH.read_text(encoding="utf-8")
|
| return HTMLResponse(content=html_content, status_code=200)
|
| except FileNotFoundError:
|
| logger.error("admin.html template not found at %s", _ADMIN_HTML_PATH)
|
| raise HTTPException(
|
| status_code=500,
|
| detail="Admin panel template is missing. Please redeploy.",
|
| )
|
|
|
|
|
| @admin_router.get("/api/admin/sessions", include_in_schema=False)
|
| async def list_sessions(
|
| search: str = "",
|
| filter: str = "",
|
| skip: int = 0,
|
| limit: int = 50,
|
| username: str = Depends(verify_admin)
|
| ):
|
| """Returns paginated session summaries sorted by most recent activity."""
|
| sessions = await get_all_sessions(search=search, filter_type=filter, skip=skip, limit=limit)
|
| return JSONResponse(content={"sessions": sessions, "total": len(sessions)})
|
|
|
| @admin_router.get("/api/admin/metrics", include_in_schema=False)
|
| async def admin_metrics_endpoint(username: str = Depends(verify_admin)):
|
| from app.services.session_store import get_metrics
|
| metrics = await get_metrics()
|
| return JSONResponse(content=metrics)
|
|
|
|
|
| @admin_router.get("/api/admin/sla-metrics", include_in_schema=False)
|
| async def admin_sla_metrics_endpoint(username: str = Depends(verify_admin)):
|
| from app.services.session_store import get_sla_metrics
|
| metrics = await get_sla_metrics()
|
| return JSONResponse(content=metrics)
|
|
|
|
|
| @admin_router.get("/api/admin/analytics", include_in_schema=False)
|
| async def admin_analytics(username: str = Depends(verify_admin)):
|
| """Returns aggregated analytics for the dashboard."""
|
| from datetime import datetime, timedelta, timezone
|
|
|
| files = await asyncio.to_thread(_list_json_files)
|
| total_sessions = len(files)
|
| total_messages = 0
|
|
|
| now_utc = datetime.now(timezone.utc)
|
| today_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
| today_sessions = 0
|
|
|
| days_data = {}
|
| for i in range(6, -1, -1):
|
| d = (today_start - timedelta(days=i)).strftime("%Y-%m-%d")
|
| days_data[d] = {"sessions": 0, "messages": 0}
|
|
|
| for fpath in files:
|
| try:
|
| data = await asyncio.to_thread(_read_json, fpath)
|
| msg_count = data.get("message_count", 0)
|
| total_messages += msg_count
|
|
|
| created_str = data.get("created_at", "")
|
| if created_str:
|
| try:
|
| created_dt = datetime.fromisoformat(created_str.replace('Z', '+00:00'))
|
| if created_dt >= today_start:
|
| today_sessions += 1
|
|
|
| date_str = created_dt.strftime("%Y-%m-%d")
|
| if date_str in days_data:
|
| days_data[date_str]["sessions"] += 1
|
| days_data[date_str]["messages"] += msg_count
|
| except ValueError:
|
| pass
|
| except Exception as e:
|
| logger.warning("Analytics: Failed to read %s: %s", fpath, e)
|
|
|
| trends = [{"date": k, "sessions": v["sessions"], "messages": v["messages"]} for k, v in days_data.items()]
|
|
|
| return JSONResponse(content={
|
| "total_sessions": total_sessions,
|
| "total_messages": total_messages,
|
| "today_sessions": today_sessions,
|
| "trends": trends
|
| })
|
|
|
|
|
| @admin_router.get("/api/admin/sessions/{session_id}", include_in_schema=False)
|
| async def get_session_detail(
|
| session_id: str, username: str = Depends(verify_admin)
|
| ):
|
| """Returns the full Q&A transcript for a specific session."""
|
| session_id = urllib.parse.unquote(session_id)
|
| if ".." in session_id or "/" in session_id or "\\" in session_id:
|
| raise HTTPException(status_code=400, detail="Invalid session ID format.")
|
|
|
| data = await get_session(session_id)
|
| if not data:
|
| raise HTTPException(
|
| status_code=404,
|
| detail=f"Session '{session_id}' not found.",
|
| )
|
| return JSONResponse(content=data)
|
|
|
|
|
| @admin_router.delete("/api/admin/sessions/{session_id}", include_in_schema=False)
|
| async def remove_session(
|
| session_id: str, username: str = Depends(verify_admin)
|
| ):
|
| """Deletes a session file permanently."""
|
| session_id = urllib.parse.unquote(session_id)
|
| if ".." in session_id or "/" in session_id or "\\" in session_id:
|
| raise HTTPException(status_code=400, detail="Invalid session ID format.")
|
|
|
| success = await delete_session(session_id)
|
| if not success:
|
| raise HTTPException(status_code=500, detail="Failed to delete session.")
|
| return JSONResponse(content={"deleted": session_id})
|
|
|
|
|
| @admin_router.delete("/api/admin/sessions/{session_id}/chats/{chat_id}", include_in_schema=False)
|
| async def delete_chat_thread(
|
| session_id: str, chat_id: int, username: str = Depends(verify_admin)
|
| ):
|
| """Deletes a specific chat thread from a session."""
|
| from app.services.session_store import _get_lock, _get_session_path, _write_json
|
|
|
| session_id = urllib.parse.unquote(session_id)
|
| if ".." in session_id or "/" in session_id or "\\" in session_id:
|
| raise HTTPException(status_code=400, detail="Invalid session ID format.")
|
|
|
| lock = await _get_lock(session_id)
|
| path = _get_session_path(session_id)
|
| async with lock:
|
| if path.exists():
|
| data = await asyncio.to_thread(_read_json, path)
|
| data["messages"] = [m for m in data.get("messages", []) if m.get("chat_id", 1) != chat_id]
|
| data["message_count"] = len(data["messages"])
|
| await asyncio.to_thread(_write_json, path, data)
|
| return {"status": "ok"}
|
|
|
|
|
| class ReplyRequest(BaseModel):
|
| message: str
|
| chat_id: Optional[int] = None
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/block", include_in_schema=False)
|
| async def block_session(session_id: str, username: str = Depends(verify_admin)):
|
| from app.services.session_store import update_session_field
|
| await update_session_field(session_id, "blocked", True)
|
| return {"status": "blocked"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/unblock", include_in_schema=False)
|
| async def unblock_session(session_id: str, username: str = Depends(verify_admin)):
|
| from app.services.session_store import update_session_field
|
| await update_session_field(session_id, "blocked", False)
|
| return {"status": "unblocked"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/takeover", include_in_schema=False)
|
| async def takeover_session(
|
| session_id: str, username: str = Depends(verify_admin)
|
| ):
|
| """Admin takes over the chat."""
|
| from app.services.session_store import update_session_field
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await update_session_field(session_id, "status", "HUMAN_TAKEOVER")
|
| if not success:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
| return {"status": "success"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/end_takeover", include_in_schema=False)
|
| async def end_takeover_session(
|
| session_id: str, username: str = Depends(verify_admin)
|
| ):
|
| """Admin returns control to AI."""
|
| from app.services.session_store import update_session_field
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await update_session_field(session_id, "status", "AI")
|
| if not success:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
| return {"status": "success"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/archive", include_in_schema=False)
|
| async def archive_session_endpoint(session_id: str, username: str = Depends(verify_admin)):
|
| from app.services.session_store import update_session_field
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await update_session_field(session_id, "archived", True)
|
| if not success:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
| return {"status": "archived"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/unarchive", include_in_schema=False)
|
| async def unarchive_session_endpoint(session_id: str, username: str = Depends(verify_admin)):
|
| from app.services.session_store import update_session_field
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await update_session_field(session_id, "archived", False)
|
| if not success:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
| return {"status": "unarchived"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/chats/{chat_id}/archive", include_in_schema=False)
|
| async def archive_chat_thread_endpoint(session_id: str, chat_id: int, username: str = Depends(verify_admin)):
|
| from app.services.session_store import archive_chat_thread, get_session
|
| session_id = urllib.parse.unquote(session_id)
|
|
|
|
|
|
|
| data = await get_session(session_id)
|
| if not data:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
|
|
| current_chat_id = data.get("current_chat_id", 1)
|
| session_status = data.get("status", "AI")
|
|
|
| is_ended = (chat_id < current_chat_id) or (session_status == "ENDED")
|
| if not is_ended:
|
| raise HTTPException(status_code=400, detail="Cannot archive an active chat. End the chat first.")
|
|
|
| success = await archive_chat_thread(session_id, chat_id, True)
|
| if not success:
|
| raise HTTPException(status_code=500, detail="Failed to archive chat thread.")
|
| return {"status": "archived"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/chats/{chat_id}/unarchive", include_in_schema=False)
|
| async def unarchive_chat_thread_endpoint(session_id: str, chat_id: int, username: str = Depends(verify_admin)):
|
| from app.services.session_store import archive_chat_thread
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await archive_chat_thread(session_id, chat_id, False)
|
| if not success:
|
| raise HTTPException(status_code=500, detail="Failed to unarchive chat thread.")
|
| return {"status": "unarchived"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/end_chat", include_in_schema=False)
|
| async def end_chat_completely(
|
| session_id: str, username: str = Depends(verify_admin)
|
| ):
|
| """Admin completely ends the chat session."""
|
| from app.services.session_store import update_session_field
|
| session_id = urllib.parse.unquote(session_id)
|
| success = await update_session_field(session_id, "status", "ENDED")
|
| if not success:
|
| raise HTTPException(status_code=404, detail="Session not found")
|
| return {"status": "success"}
|
|
|
| @admin_router.post("/api/admin/sessions/{session_id}/reply", include_in_schema=False)
|
| async def reply_session(session_id: str, req: ReplyRequest, username: str = Depends(verify_admin)):
|
| from app.services.session_store import save_message
|
|
|
|
|
| await save_message(session_id, "", req.message, chat_id=req.chat_id)
|
| return {"status": "sent"}
|
|
|
|
|
|
|
| @admin_router.get("/api/admin/export/session/{session_id}", include_in_schema=False)
|
| async def export_session(
|
| session_id: str, format: str = "excel", username: str = Depends(verify_admin)
|
| ):
|
| """Exports a single session's transcript in Excel or JSON format."""
|
| data = await get_session(session_id)
|
| if not data:
|
| raise HTTPException(status_code=404, detail="Session not found.")
|
|
|
| messages = data.get("messages", [])
|
| export_data = []
|
| for msg in messages:
|
| export_data.append({
|
| "Timestamp": msg.get("timestamp", ""),
|
| "Question": msg.get("question", ""),
|
| "Answer": msg.get("answer", "")
|
| })
|
|
|
| if format.lower() == "json":
|
| return JSONResponse(
|
| content=export_data,
|
| headers={"Content-Disposition": f"attachment; filename=session_{session_id}.json"}
|
| )
|
|
|
|
|
| df = pd.DataFrame(export_data)
|
| output = io.BytesIO()
|
| with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
| df.to_excel(writer, index=False, sheet_name='Transcript')
|
|
|
| output.seek(0)
|
| return StreamingResponse(
|
| output,
|
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| headers={"Content-Disposition": f"attachment; filename=session_{session_id}.xlsx"}
|
| )
|
|
|
|
|
| @admin_router.get("/api/admin/export/all", include_in_schema=False)
|
| async def export_all_sessions(
|
| format: str = "excel", username: str = Depends(verify_admin)
|
| ):
|
| """Exports all sessions' transcripts in a single Excel or JSON file."""
|
| files = await asyncio.to_thread(_list_json_files)
|
| all_messages = []
|
|
|
| for fpath in files:
|
| try:
|
| data = await asyncio.to_thread(_read_json, fpath)
|
| session_id = data.get("session_id", fpath.stem)
|
| for msg in data.get("messages", []):
|
| all_messages.append({
|
| "Session ID": session_id,
|
| "Timestamp": msg.get("timestamp", ""),
|
| "Question": msg.get("question", ""),
|
| "Answer": msg.get("answer", ""),
|
| "current_chat_id": data.get("current_chat_id", 1)
|
| })
|
| except Exception as e:
|
| logger.warning("Failed to read %s for export: %s", fpath, e)
|
|
|
| if format.lower() == "json":
|
| return JSONResponse(
|
| content=all_messages,
|
| headers={"Content-Disposition": "attachment; filename=all_chats_export.json"}
|
| )
|
|
|
|
|
| df = pd.DataFrame(all_messages)
|
| output = io.BytesIO()
|
| with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
| df.to_excel(writer, index=False, sheet_name='All Chats')
|
|
|
| output.seek(0)
|
| return StreamingResponse(
|
| output,
|
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| headers={"Content-Disposition": "attachment; filename=all_chats_export.xlsx"}
|
| )
|
|
|
|
|
|
|
|
|
| def _get_whatsapp_settings_path() -> Path:
|
| """Returns the path to the WhatsApp settings JSON file."""
|
| settings = get_settings()
|
| base = Path("/data") if Path("/data").exists() else Path("data")
|
| base.mkdir(parents=True, exist_ok=True)
|
| return base / "whatsapp_settings.json"
|
|
|
| def get_whatsapp_settings() -> dict:
|
| """Reads WhatsApp settings from persistent JSON file, falling back to env vars."""
|
| path = _get_whatsapp_settings_path()
|
| settings = get_settings()
|
| defaults = {
|
| "admin_number": settings.meta_whatsapp_admin_number,
|
| "phone_id": settings.meta_whatsapp_phone_id,
|
| "token": settings.meta_whatsapp_token,
|
| }
|
| if path.exists():
|
| try:
|
| with open(path, "r", encoding="utf-8") as f:
|
| saved = _json.load(f)
|
|
|
| for k in defaults:
|
| if saved.get(k):
|
| defaults[k] = saved[k]
|
| except Exception:
|
| pass
|
| return defaults
|
|
|
| def _save_whatsapp_settings(data: dict) -> None:
|
| path = _get_whatsapp_settings_path()
|
| with open(path, "w", encoding="utf-8") as f:
|
| _json.dump(data, f, indent=2)
|
|
|
|
|
| class WhatsAppSettingsRequest(BaseModel):
|
| admin_number: str = ""
|
| phone_id: str = ""
|
| token: str = ""
|
|
|
|
|
| @admin_router.get("/api/admin/settings/whatsapp", include_in_schema=False)
|
| async def get_whatsapp_config(username: str = Depends(verify_admin)):
|
| ws = get_whatsapp_settings()
|
| return {
|
| "admin_number": ws["admin_number"],
|
| "phone_id": ws["phone_id"],
|
| "token": bool(ws["token"]),
|
| }
|
|
|
|
|
| @admin_router.post("/api/admin/settings/whatsapp", include_in_schema=False)
|
| async def save_whatsapp_config(req: WhatsAppSettingsRequest, username: str = Depends(verify_admin)):
|
| current = get_whatsapp_settings()
|
| updated = {
|
| "admin_number": req.admin_number.strip() if req.admin_number else current["admin_number"],
|
| "phone_id": req.phone_id.strip() if req.phone_id else current["phone_id"],
|
| "token": req.token.strip() if req.token else current["token"],
|
| }
|
| _save_whatsapp_settings(updated)
|
| return {"status": "saved"}
|
|
|
|
|
| @admin_router.post("/api/admin/settings/whatsapp/test", include_in_schema=False)
|
| async def test_whatsapp(username: str = Depends(verify_admin)):
|
| import httpx
|
| ws = get_whatsapp_settings()
|
| if not ws["phone_id"] or not ws["token"] or not ws["admin_number"]:
|
| raise HTTPException(status_code=400, detail="WhatsApp settings incomplete. Please save all 3 fields first.")
|
|
|
| admin_numbers = [n.strip() for n in ws["admin_number"].split(",") if n.strip()]
|
| if not admin_numbers:
|
| raise HTTPException(status_code=400, detail="No valid WhatsApp numbers configured.")
|
|
|
| url = f"https://graph.facebook.com/v17.0/{ws['phone_id']}/messages"
|
| headers = {
|
| "Authorization": f"Bearer {ws['token']}",
|
| "Content-Type": "application/json"
|
| }
|
|
|
| def _send_sync():
|
| import urllib.request
|
| import urllib.error
|
| import json
|
| import ssl
|
|
|
| errors = []
|
| ctx = ssl.create_default_context()
|
| ctx.check_hostname = False
|
| ctx.verify_mode = ssl.CERT_NONE
|
|
|
| for number in admin_numbers:
|
| payload = {
|
| "messaging_product": "whatsapp",
|
| "to": number,
|
| "type": "text",
|
| "text": {"body": "β
Test notification from Martechsol Admin Panel.\nWhatsApp integration is working correctly!"}
|
| }
|
| data = json.dumps(payload).encode('utf-8')
|
| req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
| try:
|
| with urllib.request.urlopen(req, timeout=15.0, context=ctx) as response:
|
| pass
|
| except urllib.error.HTTPError as e:
|
| err_msg = e.read().decode('utf-8')
|
| detail = err_msg
|
| try:
|
| detail = json.loads(err_msg).get("error", {}).get("message", err_msg)
|
| except Exception:
|
| pass
|
| errors.append(f"Error for {number}: {detail}")
|
| except Exception as e:
|
| errors.append(f"Network error for {number}: {type(e).__name__} - {str(e)}")
|
| return errors
|
|
|
| try:
|
| import asyncio
|
| errors = await asyncio.to_thread(_send_sync)
|
| if errors:
|
| raise HTTPException(status_code=400, detail=" | ".join(errors))
|
| return {"status": "sent"}
|
| except HTTPException:
|
| raise
|
| except Exception as e:
|
| raise HTTPException(status_code=500, detail=f"Unexpected error: {type(e).__name__} - {str(e)}")
|
|
|