Files
verify/backend/app/routers/admin.py
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

438 lines
19 KiB
Python

"""Super Admin Panel — Product Admin roles only. Org utilization, plans, revenue."""
import csv
import io
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from app.core.config import settings
from app.core.deps import require_admin_roles, Principal
from app.models import (
Organization, Plan, Product, QRCode, ScanLog, StorageUsage, Subscription,
Invoice, AuditLog, OrgMember, User, SupportNote, PlanHistory,
)
from app.models.documents import now
from app.models.enums import ProductAdminRole, SubscriptionStatus
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/config-status")
async def config_status(p: Principal = Depends(require_admin_roles())):
"""Env-only credential status surfaced on the Product Admin settings page."""
return {
"google_oauth": settings.google_configured,
"razorpay": settings.razorpay_configured,
"aws_ses": settings.ses_configured,
"mock_db": settings.mock_db,
"mock_email": settings.mock_email,
"mock_storage": settings.mock_storage,
}
async def _utilization(org: Organization) -> dict:
oid = str(org.id)
plan = await Plan.get(org.plan_id) if org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == oid)
sub = await Subscription.find_one(Subscription.org_id == oid)
qr_count = await QRCode.find(QRCode.org_id == oid).count()
scans = await ScanLog.find(ScanLog.org_id == oid).count()
return {
"org_id": oid, "name": org.name, "logo_url": org.logo_url,
"plan": plan.name if plan else None,
"plan_price": plan.price if plan else 0,
"products_used": org.product_count,
"product_limit": plan.product_limit if plan else None,
"users_used": org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
"qr_codes": qr_count,
"total_scans": scans,
"subscription_status": org.subscription_status,
"renewal_date": sub.renewal_date if sub else None,
"payment_status": sub.payment_status if sub else None,
}
@router.get("/organizations")
async def list_orgs(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
return [await _utilization(o) for o in orgs]
@router.get("/organizations/{org_id}")
async def org_detail(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
util = await _utilization(org)
members = await OrgMember.find(OrgMember.org_id == org_id).to_list()
admins = []
for m in members:
u = await User.get(m.user_id)
if u:
admins.append({"name": u.name, "email": u.email, "role": m.role,
"last_login": u.last_login})
return {**util, "admins": admins}
@router.get("/organizations/{org_id}/utilization")
async def org_utilization(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
return await _utilization(org)
@router.post("/organizations/{org_id}/impersonate")
async def impersonate(org_id: str,
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="org.impersonate", module="admin",
details=f"Impersonation started for {org.name}",
impersonator_id=str(p.user.id)).insert()
return {"message": f"Impersonating {org.name}", "org_id": org_id}
@router.get("/plans")
async def list_plans(p: Principal = Depends(require_admin_roles())):
plans = await Plan.find_all().to_list()
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features,
"enabled": pl.enabled} for pl in plans]
@router.post("/plans", status_code=201)
async def create_plan(body: dict = Body(...),
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
if await Plan.find_one(Plan.name == body.get("name")):
raise HTTPException(status.HTTP_409_CONFLICT, "Plan name already exists")
plan = Plan(
name=body.get("name", "New Plan"), price=int(body.get("price", 0)),
product_limit=body.get("product_limit"), user_limit=body.get("user_limit"),
storage_gb=int(body.get("storage_gb", 5)),
grace_days=int(body.get("grace_days", 30)),
archive_years=int(body.get("archive_years", 1)),
features=body.get("features", []), enabled=body.get("enabled", True),
)
await plan.insert()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.create", module="admin",
details=f"Created plan {plan.name}").insert()
return {"id": str(plan.id)}
@router.put("/plans/{plan_id}")
async def update_plan(plan_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
for k in ("name", "price", "product_limit", "user_limit", "storage_gb",
"grace_days", "archive_years", "features", "enabled"):
if k in body:
setattr(plan, k, body[k])
await plan.save()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.update", module="admin",
details=f"Updated plan {plan.name}").insert()
return {"message": "updated"}
@router.get("/dashboard")
async def admin_dashboard(p: Principal = Depends(require_admin_roles())):
"""Platform-wide overview KPIs for the Super Admin landing page."""
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
total_qr = await QRCode.find_all().count()
total_scans = await ScanLog.find_all().count()
def lc(s): return sum(1 for o in orgs if o.subscription_status == s)
# MRR = sum of active orgs' plan prices
mrr = 0
paying = 0
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
price = plans[o.plan_id].price
if price > 0:
mrr += price
paying += 1
paid = [i for i in invoices if i.status == "paid"]
total_revenue = sum(i.total for i in paid)
# revenue trend — last 6 months by invoice month
from collections import defaultdict
buckets = defaultdict(int)
for i in paid:
key = i.created_at.strftime("%Y-%m")
buckets[key] += i.total
months = sorted(buckets.keys())[-6:]
trend = [{"month": m, "revenue": buckets[m]} for m in months]
return {
"total_orgs": len(orgs),
"active": lc(SubscriptionStatus.active),
"grace": lc(SubscriptionStatus.grace),
"archived": lc(SubscriptionStatus.archived),
"deleted": lc(SubscriptionStatus.deleted),
"mrr": mrr, "arr": mrr * 12, "paying_orgs": paying,
"arpo": round(mrr / paying) if paying else 0,
"total_revenue": total_revenue,
"total_qr": total_qr, "total_scans": total_scans,
"revenue_trend": trend,
}
@router.get("/revenue")
async def revenue(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
paid = [i for i in invoices if i.status == "paid"]
mrr = sum(plans[o.plan_id].price for o in orgs
if o.subscription_status == SubscriptionStatus.active
and o.plan_id in plans and plans[o.plan_id].price > 0)
# plan-wise breakdown of active paying orgs
from collections import defaultdict
plan_rev = defaultdict(int)
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
pl = plans[o.plan_id]
if pl.price > 0:
plan_rev[pl.name] += pl.price
org_names = {str(o.id): o.name for o in orgs}
return {
"mrr": mrr, "arr": mrr * 12,
"total_revenue": sum(i.total for i in paid),
"invoices_count": len(invoices), "paid_count": len(paid),
"plan_breakdown": [{"plan": k, "revenue": v} for k, v in plan_rev.items()],
"invoices": [{"id": str(i.id), "invoice_number": i.invoice_number,
"org": org_names.get(i.org_id, ""), "plan": i.plan_name,
"subtotal": i.subtotal, "gst": i.gst_amount, "total": i.total,
"status": i.status, "at": i.created_at} for i in invoices[:100]],
}
@router.get("/revenue/export")
async def revenue_export(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = {str(o.id): o.name for o in await Organization.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Invoice", "Organization", "Plan", "Subtotal", "GST", "Total", "Status", "Date"])
for i in invoices:
w.writerow([i.invoice_number, orgs.get(i.org_id, ""), i.plan_name,
i.subtotal, i.gst_amount, i.total, i.status, i.created_at.isoformat()])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="revenue.csv"'})
@router.get("/audit-logs")
async def audit_logs(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin)), limit: int = 100):
logs = await AuditLog.find_all().to_list()
logs.sort(key=lambda x: x.created_at, reverse=True)
return [{"at": l.created_at, "user": l.user_name, "role": l.user_role,
"action": l.action, "module": l.module, "details": l.details,
"ip": l.ip} for l in logs[:limit]]
# ---------- org detail extras: history, payments, support notes ----------
@router.get("/organizations/{org_id}/history")
async def org_history(org_id: str, p: Principal = Depends(require_admin_roles())):
subs = await Subscription.find(Subscription.org_id == org_id).to_list()
invs = await Invoice.find(Invoice.org_id == org_id).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
plan_hist = await PlanHistory.find(PlanHistory.org_id == org_id).to_list()
plan_hist.sort(key=lambda h: h.created_at, reverse=True)
return {
"subscriptions": [{"plan": s.plan_name, "status": s.status,
"start": s.start_date, "renewal": s.renewal_date,
"payment_status": s.payment_status} for s in subs],
"invoices": [{"invoice_number": i.invoice_number, "total": i.total,
"status": i.status, "at": i.created_at} for i in invs],
"plan_history": [{"from": h.from_plan, "to": h.to_plan,
"reason": h.reason, "at": h.created_at} for h in plan_hist],
}
@router.get("/organizations/{org_id}/notes")
async def list_notes(org_id: str, p: Principal = Depends(require_admin_roles())):
notes = await SupportNote.find(SupportNote.org_id == org_id).to_list()
notes.sort(key=lambda n: n.created_at, reverse=True)
return [{"id": str(n.id), "note": n.note, "flag": n.flag,
"author": n.author_name, "at": n.created_at} for n in notes]
@router.post("/organizations/{org_id}/notes", status_code=201)
async def add_note(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles())):
note = SupportNote(org_id=org_id, author_id=str(p.user.id),
author_name=p.user.name, note=body.get("note", ""),
flag=body.get("flag"))
await note.insert()
return {"id": str(note.id)}
# ---------- subscription override / extend / reminder ----------
@router.put("/organizations/{org_id}/subscription")
async def override_subscription(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin))):
org = await Organization.get(org_id)
sub = await Subscription.find_one(Subscription.org_id == org_id)
if not org or not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org or subscription not found")
if "status" in body and body["status"] in [s.value for s in SubscriptionStatus]:
org.subscription_status = SubscriptionStatus(body["status"])
sub.status = org.subscription_status
if body.get("extend_days"):
base = sub.renewal_date or now()
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
sub.renewal_date = base + timedelta(days=int(body["extend_days"]))
if body.get("plan_id"):
plan = await Plan.get(body["plan_id"])
if plan:
org.plan_id = str(plan.id)
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
await org.save()
await sub.save()
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="subscription.override", module="admin",
details=f"Admin override on {org.name}").insert()
return {"message": "updated", "status": org.subscription_status}
@router.post("/organizations/{org_id}/send-reminder")
async def send_reminder(org_id: str, p: Principal = Depends(require_admin_roles())):
from app.services.email import send_email
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
to = org.contact_email or "admin@example.com"
await send_email(to, "VerifyPack subscription renewal reminder",
f"Hi {org.name}, your subscription is due for renewal.", "renewal_reminder")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="reminder.send", module="admin",
details=f"Renewal reminder to {to}").insert()
return {"sent": True, "to": to}
# ---------- subscription lifecycle + utilization report ----------
@router.get("/organizations/{org_id}/support-view")
async def support_view(org_id: str, p: Principal = Depends(require_admin_roles())):
"""Read-only debugging view for Support staff."""
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
products = await Product.find(Product.org_id == org_id).to_list()
qrs = await QRCode.find(QRCode.org_id == org_id).to_list()
scans = await ScanLog.find(ScanLog.org_id == org_id).to_list()
scans.sort(key=lambda s: s.scanned_at, reverse=True)
return {
"org": {"name": org.name, "slug": org.slug, "status": org.subscription_status,
"gstin": org.gstin, "contact_email": org.contact_email},
"products": [{"name": x.name, "sku": x.sku, "status": x.status,
"qr_count": x.qr_count} for x in products],
"qr_codes": [{"code": q.code, "status": q.status, "scans": q.scan_count}
for q in qrs[:50]],
"recent_scans": [{"at": s.scanned_at, "code": s.qr_code, "city": s.city,
"device": s.device} for s in scans[:25]],
}
@router.get("/subscription-lifecycle")
async def lifecycle(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
def count(s): return sum(1 for o in orgs if o.subscription_status == s)
upcoming = []
for o in orgs:
sub = await Subscription.find_one(Subscription.org_id == str(o.id))
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days = (rd - now()).days
if 0 <= days <= 30:
upcoming.append({"org": o.name, "org_id": str(o.id),
"renewal": sub.renewal_date, "days": days})
upcoming.sort(key=lambda x: x["days"])
return {
"active": count(SubscriptionStatus.active),
"grace": count(SubscriptionStatus.grace),
"archived": count(SubscriptionStatus.archived),
"deleted": count(SubscriptionStatus.deleted),
"upcoming_expiries": upcoming,
}
@router.get("/utilization-report")
async def utilization_report(format: str = "csv",
p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
rows = [await _utilization(o) for o in orgs]
if format == "pdf":
# Simple PDF table via reportlab
import io as _io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
buf = _io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
y = h - 25 * mm
c.setFont("Helvetica-Bold", 14)
c.drawString(20 * mm, y, "VerifyPack — Utilization Report")
c.setFont("Helvetica", 8)
y -= 12 * mm
c.drawString(20 * mm, y, "Org / Plan / Products / Users / QRs / Scans / Status")
y -= 6 * mm
for r in rows:
line = (f"{r['name'][:24]:24} {str(r['plan'] or '-')[:10]:10} "
f"{r['products_used']}/{r['product_limit'] or ''} "
f"{r['users_used']}/{r['user_limit'] or ''} "
f"{r['qr_codes']} {r['total_scans']} {r['subscription_status']}")
c.drawString(20 * mm, y, line)
y -= 5 * mm
if y < 20 * mm:
c.showPage(); y = h - 25 * mm
c.showPage(); c.save()
return Response(content=buf.getvalue(), media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="utilization.pdf"'})
# CSV
buf = io.StringIO()
wr = csv.writer(buf)
wr.writerow(["Organization", "Plan", "Price", "Products", "Product Limit",
"Users", "User Limit", "Storage GB", "QR Codes", "Total Scans", "Status"])
for r in rows:
wr.writerow([r["name"], r["plan"], r["plan_price"], r["products_used"],
r["product_limit"], r["users_used"], r["user_limit"],
r["storage_used_gb"], r["qr_codes"], r["total_scans"],
r["subscription_status"]])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="utilization.csv"'})