Skip to content

Commit bf5b6f2

Browse files
committed
admin panel
1 parent 98b6a36 commit bf5b6f2

4 files changed

Lines changed: 230 additions & 2 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ REPORT_PRICE_PAISE=99900
4444
# - Get Firebase service account from: Firebase Console > Project Settings > Service Accounts > Generate New Private Key
4545
# - Get Razorpay keys from: Dashboard > Settings > API Keys (generate separately in Test and Live mode)
4646
# - Set RAZORPAY_MODE=live when ready for production payments
47+
48+
# 6) Admin Panel
49+
# Comma-separated list of email addresses that have admin access
50+
ADMIN_EMAILS=admin@example.com

app.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@
4141
update_questionnaire_upload_metadata,
4242
get_user_by_firebase_uid,
4343
create_or_update_user,
44+
list_all_users,
45+
delete_user,
46+
set_user_credits,
47+
get_user_count,
48+
get_payment_history,
4449
)
4550

4651
# Auth and Payment modules
47-
from auth import require_auth, require_payment, consume_credit, verify_firebase_token, optional_auth
52+
from auth import require_auth, require_payment, consume_credit, verify_firebase_token, optional_auth, require_admin
4853
from payment import (
4954
create_razorpay_order,
5055
verify_razorpay_signature,
@@ -6157,8 +6162,111 @@ def payment_reconcile():
61576162
"message": "Could not check payment status with Razorpay"
61586163
}), 500
61596164

6165+
# =============================================================================
6166+
# ADMIN ROUTES
6167+
# =============================================================================
6168+
6169+
@app.route("/api/admin/stats", methods=["GET"])
6170+
@require_auth
6171+
@require_admin
6172+
def admin_stats():
6173+
"""Get dashboard statistics: total users, paid users, users with credits."""
6174+
counts = get_user_count()
6175+
return jsonify({"success": True, "stats": counts}), 200
6176+
6177+
6178+
@app.route("/api/admin/users", methods=["GET"])
6179+
@require_auth
6180+
@require_admin
6181+
def admin_list_users():
6182+
"""
6183+
List all users with pagination and optional search.
6184+
Query params: page (default 1), per_page (default 25), search (optional)
6185+
"""
6186+
page = request.args.get("page", 1, type=int)
6187+
per_page = request.args.get("per_page", 25, type=int)
6188+
search = request.args.get("search", "").strip() or None
6189+
6190+
# Clamp per_page to prevent abuse
6191+
per_page = min(max(per_page, 1), 100)
6192+
6193+
users, total = list_all_users(page=page, per_page=per_page, search=search)
6194+
return jsonify({
6195+
"success": True,
6196+
"users": users,
6197+
"pagination": {
6198+
"page": page,
6199+
"per_page": per_page,
6200+
"total": total,
6201+
"pages": (total + per_page - 1) // per_page if per_page else 1,
6202+
},
6203+
}), 200
6204+
6205+
6206+
@app.route("/api/admin/users/<firebase_uid>", methods=["GET"])
6207+
@require_auth
6208+
@require_admin
6209+
def admin_get_user(firebase_uid):
6210+
"""Get single user details including payment history."""
6211+
user = get_user_by_firebase_uid(firebase_uid)
6212+
if not user:
6213+
return jsonify({"error": "User not found"}), 404
6214+
6215+
payments = get_payment_history(firebase_uid)
6216+
return jsonify({
6217+
"success": True,
6218+
"user": user,
6219+
"payments": payments,
6220+
}), 200
6221+
6222+
6223+
@app.route("/api/admin/users/<firebase_uid>", methods=["DELETE"])
6224+
@require_auth
6225+
@require_admin
6226+
def admin_delete_user(firebase_uid):
6227+
"""Delete a user and their payment history."""
6228+
deleted = delete_user(firebase_uid)
6229+
if not deleted:
6230+
return jsonify({"error": "User not found"}), 404
6231+
return jsonify({"success": True, "message": "User deleted"}), 200
6232+
6233+
6234+
@app.route("/api/admin/users/<firebase_uid>/credits", methods=["POST"])
6235+
@require_auth
6236+
@require_admin
6237+
def admin_set_credits(firebase_uid):
6238+
"""
6239+
Set or add credits for a user.
6240+
Request body: { "credits": int, "mode": "set" | "add" }
6241+
"""
6242+
data = request.get_json(force=True) or {}
6243+
credits = data.get("credits")
6244+
mode = data.get("mode", "set")
6245+
6246+
if credits is None or not isinstance(credits, int) or credits < 0:
6247+
return jsonify({"error": "Valid non-negative integer 'credits' required"}), 400
6248+
6249+
user = get_user_by_firebase_uid(firebase_uid)
6250+
if not user:
6251+
return jsonify({"error": "User not found"}), 404
6252+
6253+
if mode == "add":
6254+
from db import add_user_credits
6255+
success = add_user_credits(firebase_uid, credits)
6256+
else:
6257+
success = set_user_credits(firebase_uid, credits)
6258+
6259+
if not success:
6260+
return jsonify({"error": "Failed to update credits"}), 500
6261+
6262+
updated_user = get_user_by_firebase_uid(firebase_uid)
6263+
return jsonify({
6264+
"success": True,
6265+
"message": f"Credits {'added' if mode == 'add' else 'set'} successfully",
6266+
"report_credits": updated_user.get("report_credits", 0),
6267+
}), 200
6268+
61606269

61616270
if __name__ == '__main__':
61626271
port = int(os.environ.get('PORT', 5000))
61636272
app.run(host='0.0.0.0', port=port, debug=False)
6164-

auth.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,43 @@ def decorated_function(*args, **kwargs):
253253

254254
return f(*args, **kwargs)
255255
return decorated_function
256+
257+
258+
def require_admin(f):
259+
"""
260+
Decorator that requires the authenticated user to be an admin.
261+
262+
Must be used AFTER @require_auth decorator.
263+
Checks g.firebase_token["email"] against the ADMIN_EMAILS environment variable
264+
(comma-separated list of admin email addresses).
265+
Returns 403 Forbidden if not an admin.
266+
"""
267+
@functools.wraps(f)
268+
def decorated_function(*args, **kwargs):
269+
# Ensure require_auth was called first
270+
if not hasattr(g, 'firebase_token') or g.firebase_token is None:
271+
return jsonify({
272+
"error": "Authentication required",
273+
"message": "This endpoint requires authentication"
274+
}), 401
275+
276+
user_email = (g.firebase_token.get("email") or "").lower().strip()
277+
admin_emails_raw = os.getenv("ADMIN_EMAILS", "")
278+
admin_emails = [
279+
e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()
280+
]
281+
282+
if not admin_emails:
283+
return jsonify({
284+
"error": "Admin not configured",
285+
"message": "No admin emails have been configured on the server"
286+
}), 403
287+
288+
if user_email not in admin_emails:
289+
return jsonify({
290+
"error": "Forbidden",
291+
"message": "You do not have admin access"
292+
}), 403
293+
294+
return f(*args, **kwargs)
295+
return decorated_function

db.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,82 @@ def migrate_existing_paid_users(credits: int = 3) -> int:
625625
return result.rowcount
626626

627627

628+
# --- Admin helpers ---
629+
630+
def list_all_users(page: int = 1, per_page: int = 25, search: Optional[str] = None):
631+
"""List all users with pagination and optional email search. Returns (users, total)."""
632+
offset = (page - 1) * per_page
633+
634+
if search:
635+
like = f"%{search}%"
636+
total_rows = _query(
637+
"SELECT COUNT(*) AS cnt FROM users WHERE email LIKE ? OR display_name LIKE ?",
638+
(like, like),
639+
)
640+
total = total_rows[0]["cnt"] if total_rows else 0
641+
rows = _query(
642+
"""
643+
SELECT id, firebase_uid, email, display_name, has_paid,
644+
report_credits, payment_date, created_at, updated_at
645+
FROM users
646+
WHERE email LIKE ? OR display_name LIKE ?
647+
ORDER BY created_at DESC
648+
LIMIT ? OFFSET ?
649+
""",
650+
(like, like, per_page, offset),
651+
)
652+
else:
653+
total_rows = _query("SELECT COUNT(*) AS cnt FROM users")
654+
total = total_rows[0]["cnt"] if total_rows else 0
655+
rows = _query(
656+
"""
657+
SELECT id, firebase_uid, email, display_name, has_paid,
658+
report_credits, payment_date, created_at, updated_at
659+
FROM users
660+
ORDER BY created_at DESC
661+
LIMIT ? OFFSET ?
662+
""",
663+
(per_page, offset),
664+
)
665+
666+
users = [dict(r) for r in rows]
667+
return users, total
668+
669+
670+
def delete_user(firebase_uid: str) -> bool:
671+
"""Delete a user and their payment history. Returns True if user existed."""
672+
user = get_user_by_firebase_uid(firebase_uid)
673+
if not user:
674+
return False
675+
_exec("DELETE FROM payments WHERE firebase_uid = ?", (firebase_uid,))
676+
_exec("DELETE FROM users WHERE firebase_uid = ?", (firebase_uid,))
677+
return True
678+
679+
680+
def set_user_credits(firebase_uid: str, credits: int) -> bool:
681+
"""Set credits to an exact value. Returns True if updated."""
682+
result = _exec(
683+
"""
684+
UPDATE users SET report_credits = ?,
685+
updated_at = CURRENT_TIMESTAMP WHERE firebase_uid = ?
686+
""",
687+
(credits, firebase_uid),
688+
)
689+
return result.rowcount > 0
690+
691+
692+
def get_user_count() -> Dict[str, int]:
693+
"""Get total and paid user counts for dashboard stats."""
694+
total_rows = _query("SELECT COUNT(*) AS cnt FROM users")
695+
paid_rows = _query("SELECT COUNT(*) AS cnt FROM users WHERE has_paid = 1")
696+
credits_rows = _query("SELECT COUNT(*) AS cnt FROM users WHERE report_credits > 0")
697+
return {
698+
"total": total_rows[0]["cnt"] if total_rows else 0,
699+
"paid": paid_rows[0]["cnt"] if paid_rows else 0,
700+
"with_credits": credits_rows[0]["cnt"] if credits_rows else 0,
701+
}
702+
703+
628704
# Initialize schema on import
629705
init_db()
630706

0 commit comments

Comments
 (0)