mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 19:26:33 +00:00
1825 lines
69 KiB
Python
1825 lines
69 KiB
Python
from flask import Blueprint, request, jsonify
|
|
from functools import wraps
|
|
import uuid
|
|
from datetime import datetime
|
|
from pymongo import MongoClient
|
|
from pymongo import DESCENDING
|
|
import os
|
|
from bson import ObjectId
|
|
import re
|
|
import json
|
|
import csv
|
|
import io
|
|
import hashlib
|
|
from activity_logger import log_user_activity
|
|
|
|
bp = Blueprint('admin', __name__)
|
|
|
|
# MongoDB connection
|
|
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
|
client = MongoClient(mongo_uri)
|
|
db = client.openlearnx
|
|
|
|
|
|
def _safe_json_payload():
|
|
"""Safely parse JSON body without throwing on malformed input."""
|
|
try:
|
|
return request.get_json(silent=True) or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _client_ip():
|
|
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
|
if forwarded_for:
|
|
return forwarded_for.split(",")[0].strip()
|
|
return request.remote_addr or "unknown"
|
|
|
|
|
|
def _admin_log(event_type, action, status_code=200, severity="info", metadata=None):
|
|
"""Write an admin/security log entry for auditability."""
|
|
try:
|
|
request_headers = {}
|
|
for key, value in request.headers.items():
|
|
if key.lower() in {"authorization", "cookie", "set-cookie"}:
|
|
request_headers[key] = "[redacted]"
|
|
else:
|
|
request_headers[key] = value
|
|
|
|
request_body = ""
|
|
try:
|
|
request_body = request.get_data(cache=True, as_text=True)[:4000]
|
|
except Exception:
|
|
request_body = ""
|
|
|
|
log_doc = {
|
|
"timestamp": datetime.utcnow(),
|
|
"event_type": event_type,
|
|
"action": action,
|
|
"status_code": int(status_code),
|
|
"severity": severity,
|
|
"path": request.path,
|
|
"method": request.method,
|
|
"ip": _client_ip(),
|
|
"user_agent": request.headers.get("User-Agent", ""),
|
|
"metadata": {
|
|
**(metadata or {}),
|
|
"request_body": request_body,
|
|
"request_details": {
|
|
"query": dict(request.args),
|
|
"json": _safe_json_payload(),
|
|
"headers": request_headers,
|
|
"content_type": request.headers.get("Content-Type", ""),
|
|
},
|
|
"response_details": {
|
|
"status_code": int(status_code),
|
|
},
|
|
},
|
|
"metadata_text": str(metadata or {})
|
|
}
|
|
db.security_logs.insert_one(log_doc)
|
|
except Exception as log_error:
|
|
print(f"Log write failed: {log_error}")
|
|
|
|
|
|
def _json_safe(value):
|
|
"""Convert ObjectId/datetime/nested structures into JSON-safe values."""
|
|
if isinstance(value, ObjectId):
|
|
return str(value)
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
if isinstance(value, dict):
|
|
return {k: _json_safe(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_json_safe(v) for v in value]
|
|
return value
|
|
|
|
|
|
def _to_object_id(value):
|
|
try:
|
|
return ObjectId(value)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _hash_password(password):
|
|
return hashlib.sha256(password.encode()).hexdigest()
|
|
|
|
def admin_required(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
try:
|
|
auth_header = request.headers.get('Authorization')
|
|
print(f"Admin auth check - Header: {auth_header}")
|
|
|
|
if not auth_header:
|
|
print("❌ No Authorization header")
|
|
_admin_log("admin_auth_failed", "missing_authorization_header", status_code=401, severity="warning")
|
|
return jsonify({"error": "No authorization header provided"}), 401
|
|
|
|
if not auth_header.startswith('Bearer '):
|
|
print("❌ Invalid authorization format")
|
|
_admin_log("admin_auth_failed", "invalid_authorization_format", status_code=401, severity="warning")
|
|
return jsonify({"error": "Invalid authorization format"}), 401
|
|
|
|
token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
|
|
|
|
# Check environment variable - no fallback for security
|
|
expected_token = os.getenv('ADMIN_TOKEN')
|
|
if not expected_token:
|
|
print("❌ ADMIN_TOKEN environment variable not set")
|
|
_admin_log("admin_auth_failed", "admin_token_not_configured", status_code=500, severity="error")
|
|
return jsonify({"error": "Server configuration error: ADMIN_TOKEN not configured"}), 500
|
|
|
|
# Strip any whitespace from both tokens
|
|
if token and expected_token:
|
|
if token.strip() == expected_token.strip():
|
|
print("✅ Admin authentication successful")
|
|
_admin_log("admin_auth_success", "token_validated", status_code=200)
|
|
return f(*args, **kwargs)
|
|
|
|
print("❌ Token mismatch")
|
|
_admin_log("admin_auth_failed", "token_mismatch", status_code=401, severity="warning")
|
|
return jsonify({"error": "Invalid admin token"}), 401
|
|
|
|
except Exception as e:
|
|
print(f"❌ Admin auth error: {str(e)}")
|
|
_admin_log("admin_auth_error", "authentication_exception", status_code=500, severity="error", metadata={"error": str(e)})
|
|
return jsonify({"error": "Authentication failed"}), 500
|
|
|
|
return decorated_function
|
|
|
|
def serialize_document(doc):
|
|
"""Convert MongoDB document to JSON-serializable format"""
|
|
if doc:
|
|
if '_id' in doc:
|
|
doc['_id'] = str(doc['_id'])
|
|
return doc
|
|
return None
|
|
|
|
def serialize_course(course):
|
|
"""Convert MongoDB document to JSON-serializable format"""
|
|
if course:
|
|
if '_id' in course:
|
|
del course['_id']
|
|
return course
|
|
return None
|
|
|
|
def convert_to_embed_url(youtube_url):
|
|
"""Convert YouTube watch URL to embed URL - ENHANCED VERSION"""
|
|
if not youtube_url:
|
|
return None
|
|
|
|
try:
|
|
if "youtu.be/" in youtube_url:
|
|
video_id = youtube_url.split("youtu.be/")[1].split("?")[0].split("&")[0]
|
|
elif "youtube.com/watch?v=" in youtube_url:
|
|
video_id = youtube_url.split("v=")[1].split("&")[0]
|
|
elif "youtube.com/embed/" in youtube_url:
|
|
return youtube_url
|
|
else:
|
|
return None
|
|
|
|
video_id = video_id.strip()
|
|
return f"https://www.youtube.com/embed/{video_id}?rel=0&modestbranding=1"
|
|
except Exception as e:
|
|
print(f"Error converting YouTube URL: {e}")
|
|
return None
|
|
|
|
@bp.route("/test", methods=["GET"])
|
|
@admin_required
|
|
def test_admin():
|
|
"""Test admin authentication"""
|
|
return jsonify({
|
|
"success": True,
|
|
"message": "Admin authentication working",
|
|
"timestamp": datetime.now().isoformat()
|
|
})
|
|
|
|
@bp.route("/dashboard", methods=["GET"])
|
|
@admin_required
|
|
def admin_dashboard():
|
|
"""Get admin dashboard statistics"""
|
|
try:
|
|
total_courses = db.courses.count_documents({})
|
|
|
|
# ✅ FIXED: Count modules and lessons from embedded course structure
|
|
total_modules = 0
|
|
total_lessons = 0
|
|
courses = list(db.courses.find({}, {"modules": 1}))
|
|
for course in courses:
|
|
modules = course.get("modules", [])
|
|
total_modules += len(modules)
|
|
for module in modules:
|
|
lessons = module.get("lessons", [])
|
|
total_lessons += len(lessons)
|
|
|
|
# Fallback to separate collections if they exist
|
|
if total_modules == 0:
|
|
total_modules = db.modules.count_documents({})
|
|
if total_lessons == 0:
|
|
total_lessons = db.lessons.count_documents({})
|
|
|
|
total_users = db.users.count_documents({})
|
|
total_logs = db.security_logs.count_documents({})
|
|
active_students = db.users.count_documents({
|
|
"$or": [
|
|
{"status": "active"},
|
|
{"last_login": {"$exists": True}},
|
|
{"login_count": {"$gt": 0}}
|
|
]
|
|
})
|
|
|
|
# Real completion rate from user_courses when available.
|
|
completion_rate = 0
|
|
user_course_docs = list(db.user_courses.find(
|
|
{"completion_percentage": {"$exists": True}},
|
|
{"_id": 0, "completion_percentage": 1}
|
|
))
|
|
if user_course_docs:
|
|
total_completion = 0.0
|
|
for doc in user_course_docs:
|
|
try:
|
|
total_completion += float(doc.get("completion_percentage", 0))
|
|
except Exception:
|
|
total_completion += 0.0
|
|
completion_rate = round(total_completion / len(user_course_docs))
|
|
|
|
# Fallback completion estimate from submissions when course progress docs are absent.
|
|
if completion_rate == 0:
|
|
total_submissions = db.user_submissions.count_documents({})
|
|
total_users = max(db.users.count_documents({}), 1)
|
|
if total_submissions > 0:
|
|
completion_rate = min(round((total_submissions / total_users) * 10), 100)
|
|
|
|
stats = {
|
|
"total_courses": total_courses,
|
|
"total_lessons": total_lessons,
|
|
"total_modules": total_modules,
|
|
"total_users": total_users,
|
|
"total_logs": total_logs,
|
|
"active_students": active_students,
|
|
"completion_rate": completion_rate
|
|
}
|
|
_admin_log("admin_dashboard_view", "fetch_dashboard_stats", status_code=200, metadata=stats)
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
print(f"Dashboard error: {str(e)}")
|
|
_admin_log("admin_dashboard_error", "fetch_dashboard_stats_failed", status_code=500, severity="error", metadata={"error": str(e)})
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/logs/client-event", methods=["POST"])
|
|
@admin_required
|
|
def create_client_log_event():
|
|
"""Accept explicit frontend events (admin visits, UI actions, etc.)."""
|
|
try:
|
|
data = _safe_json_payload()
|
|
event_type = (data.get("event_type") or "client_event").strip().lower()
|
|
action = (data.get("action") or "unknown_action").strip()
|
|
severity = (data.get("severity") or "info").strip().lower()
|
|
metadata = data.get("metadata") or {}
|
|
_admin_log(event_type, action, status_code=200, severity=severity, metadata=metadata)
|
|
return jsonify({"success": True, "message": "Event logged"})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/logs", methods=["GET"])
|
|
@admin_required
|
|
def get_admin_logs():
|
|
"""Query security and activity logs with filters for the admin panel."""
|
|
try:
|
|
event_type = request.args.get("event_type", "").strip()
|
|
severity = request.args.get("severity", "").strip()
|
|
status_code = request.args.get("status_code", "").strip()
|
|
search = request.args.get("search", "").strip()
|
|
from_ts = request.args.get("from", "").strip()
|
|
to_ts = request.args.get("to", "").strip()
|
|
limit = min(max(int(request.args.get("limit", 100)), 1), 500)
|
|
page = max(int(request.args.get("page", 1)), 1)
|
|
|
|
query = {}
|
|
if event_type:
|
|
query["event_type"] = event_type
|
|
if severity:
|
|
query["severity"] = severity
|
|
if status_code:
|
|
try:
|
|
query["status_code"] = int(status_code)
|
|
except Exception:
|
|
pass
|
|
|
|
ts_filter = {}
|
|
if from_ts:
|
|
try:
|
|
ts_filter["$gte"] = datetime.fromisoformat(from_ts)
|
|
except Exception:
|
|
pass
|
|
if to_ts:
|
|
try:
|
|
ts_filter["$lte"] = datetime.fromisoformat(to_ts)
|
|
except Exception:
|
|
pass
|
|
if ts_filter:
|
|
query["timestamp"] = ts_filter
|
|
|
|
if search:
|
|
safe = re.escape(search)
|
|
query["$or"] = [
|
|
{"action": {"$regex": safe, "$options": "i"}},
|
|
{"path": {"$regex": safe, "$options": "i"}},
|
|
{"ip": {"$regex": safe, "$options": "i"}},
|
|
{"event_type": {"$regex": safe, "$options": "i"}},
|
|
{"metadata_text": {"$regex": safe, "$options": "i"}}
|
|
]
|
|
|
|
skip = (page - 1) * limit
|
|
total = db.security_logs.count_documents(query)
|
|
logs = list(db.security_logs.find(query).sort("timestamp", -1).skip(skip).limit(limit))
|
|
|
|
for item in logs:
|
|
item["id"] = str(item.get("_id"))
|
|
item.pop("_id", None)
|
|
ts = item.get("timestamp")
|
|
if isinstance(ts, datetime):
|
|
item["timestamp"] = ts.isoformat()
|
|
|
|
metadata = item.get("metadata") or {}
|
|
request_details = metadata.get("request_details") or {}
|
|
response_details = metadata.get("response_details") or {}
|
|
usage_details = metadata.get("usage") or {}
|
|
|
|
request_body = metadata.get("request_body")
|
|
if request_body in (None, ""):
|
|
request_body = request_details or {"note": "No request body captured", "query": item.get("query", {})}
|
|
|
|
response_body = metadata.get("response_body")
|
|
if response_body in (None, ""):
|
|
response_body = response_details or {
|
|
"note": "No response body captured for this log entry",
|
|
"status_code": item.get("status_code"),
|
|
"path": item.get("path"),
|
|
"method": item.get("method"),
|
|
}
|
|
|
|
usage = usage_details or {
|
|
"duration_ms": item.get("duration_ms", 0),
|
|
"note": "Usage metrics not captured for this log entry",
|
|
}
|
|
|
|
item["request_body"] = request_body
|
|
item["response_body"] = response_body
|
|
item["usage"] = usage
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"logs": logs,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + limit - 1) // limit
|
|
}
|
|
})
|
|
except Exception as e:
|
|
print(f"Error getting logs: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/logs/executions", methods=["GET"])
|
|
@admin_required
|
|
def get_execution_logs():
|
|
"""Query compiler/coding execution events as a dedicated log stream."""
|
|
try:
|
|
language = request.args.get("language", "").strip().lower()
|
|
status = request.args.get("status", "").strip().lower()
|
|
search = request.args.get("search", "").strip()
|
|
from_ts = request.args.get("from", "").strip()
|
|
to_ts = request.args.get("to", "").strip()
|
|
limit = min(max(int(request.args.get("limit", 100)), 1), 500)
|
|
page = max(int(request.args.get("page", 1)), 1)
|
|
|
|
query = {}
|
|
if language:
|
|
query["language"] = language
|
|
if status:
|
|
query["status"] = status
|
|
|
|
ts_filter = {}
|
|
if from_ts:
|
|
try:
|
|
ts_filter["$gte"] = datetime.fromisoformat(from_ts)
|
|
except Exception:
|
|
pass
|
|
if to_ts:
|
|
try:
|
|
ts_filter["$lte"] = datetime.fromisoformat(to_ts)
|
|
except Exception:
|
|
pass
|
|
if ts_filter:
|
|
query["timestamp"] = ts_filter
|
|
|
|
if search:
|
|
safe = re.escape(search)
|
|
query["$or"] = [
|
|
{"execution_id": {"$regex": safe, "$options": "i"}},
|
|
{"language": {"$regex": safe, "$options": "i"}},
|
|
{"source": {"$regex": safe, "$options": "i"}},
|
|
{"ip": {"$regex": safe, "$options": "i"}},
|
|
{"status": {"$regex": safe, "$options": "i"}},
|
|
{"error": {"$regex": safe, "$options": "i"}},
|
|
]
|
|
|
|
skip = (page - 1) * limit
|
|
total = db.code_execution_events.count_documents(query)
|
|
docs = list(db.code_execution_events.find(query).sort("timestamp", -1).skip(skip).limit(limit))
|
|
|
|
logs = []
|
|
for doc in docs:
|
|
item = _json_safe(doc)
|
|
item["id"] = str(item.get("_id"))
|
|
item.pop("_id", None)
|
|
|
|
if not item.get("source"):
|
|
item["source"] = "compiler"
|
|
|
|
if not item.get("request_body"):
|
|
item["request_body"] = {
|
|
"language": item.get("language", "unknown"),
|
|
"code": "Legacy log entry (request code body was not captured at execution time)",
|
|
"code_size": item.get("code_size", 0),
|
|
}
|
|
|
|
if not item.get("response_body"):
|
|
item["response_body"] = {
|
|
"success": item.get("status") == "success",
|
|
"blocked": bool(item.get("blocked")),
|
|
"execution_id": item.get("execution_id"),
|
|
"error": item.get("error", ""),
|
|
"security_violations": item.get("security_violations", []),
|
|
"execution_time": item.get("execution_time", 0),
|
|
"memory_used": item.get("memory_used", 0),
|
|
"exit_code": item.get("exit_code", 0),
|
|
"note": "Legacy log entry (response payload was not captured at execution time)",
|
|
}
|
|
|
|
logs.append(item)
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"logs": logs,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + limit - 1) // limit,
|
|
},
|
|
})
|
|
except Exception as e:
|
|
print(f"Error getting execution logs: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users", methods=["GET"])
|
|
@admin_required
|
|
def get_admin_users():
|
|
"""Get users with full details, pagination, and search."""
|
|
try:
|
|
page = max(int(request.args.get("page", 1)), 1)
|
|
limit = min(max(int(request.args.get("limit", 25)), 1), 200)
|
|
search = request.args.get("search", "").strip()
|
|
sort_by = request.args.get("sort_by", "created_at").strip()
|
|
sort_order = request.args.get("sort_order", "desc").strip().lower()
|
|
|
|
query = {}
|
|
if search:
|
|
safe = re.escape(search)
|
|
query["$or"] = [
|
|
{"email": {"$regex": safe, "$options": "i"}},
|
|
{"username": {"$regex": safe, "$options": "i"}},
|
|
{"name": {"$regex": safe, "$options": "i"}},
|
|
{"wallet_address": {"$regex": safe, "$options": "i"}}
|
|
]
|
|
|
|
direction = DESCENDING if sort_order != "asc" else 1
|
|
skip = (page - 1) * limit
|
|
|
|
total = db.users.count_documents(query)
|
|
docs = list(db.users.find(query).sort(sort_by, direction).skip(skip).limit(limit))
|
|
users = [_json_safe(doc) for doc in docs]
|
|
|
|
_admin_log("admin_users_view", "fetch_users", status_code=200, metadata={"page": page, "limit": limit, "total": total})
|
|
return jsonify({
|
|
"success": True,
|
|
"users": users,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + limit - 1) // limit
|
|
}
|
|
})
|
|
except Exception as e:
|
|
print(f"Error fetching users: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/database/overview", methods=["GET"])
|
|
@admin_required
|
|
def get_database_overview():
|
|
"""Get all collection names and counts for admin data explorer."""
|
|
try:
|
|
collection_names = db.list_collection_names()
|
|
collections = []
|
|
total_documents = 0
|
|
|
|
for name in sorted(collection_names):
|
|
count = db[name].count_documents({})
|
|
collections.append({"name": name, "count": count})
|
|
total_documents += count
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"database": "openlearnx",
|
|
"collections": collections,
|
|
"summary": {
|
|
"collection_count": len(collections),
|
|
"total_documents": total_documents
|
|
}
|
|
})
|
|
except Exception as e:
|
|
print(f"Error fetching database overview: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/database/collections/<collection_name>", methods=["GET"])
|
|
@admin_required
|
|
def get_collection_documents(collection_name):
|
|
"""Get documents for any collection with pagination and text filtering."""
|
|
try:
|
|
if collection_name not in db.list_collection_names():
|
|
return jsonify({"error": "Collection not found"}), 404
|
|
|
|
page = max(int(request.args.get("page", 1)), 1)
|
|
limit = min(max(int(request.args.get("limit", 25)), 1), 200)
|
|
search = request.args.get("search", "").strip().lower()
|
|
|
|
collection = db[collection_name]
|
|
total = collection.count_documents({})
|
|
|
|
# Pull a window and filter in-memory for generic full-document search.
|
|
skip = (page - 1) * limit
|
|
raw_docs = list(collection.find({}).sort("_id", DESCENDING).skip(skip).limit(limit * 3))
|
|
docs = [_json_safe(d) for d in raw_docs]
|
|
|
|
if search:
|
|
docs = [d for d in docs if search in json.dumps(d, default=str).lower()]
|
|
|
|
docs = docs[:limit]
|
|
|
|
_admin_log("admin_database_view", "fetch_collection_documents", status_code=200, metadata={
|
|
"collection": collection_name,
|
|
"page": page,
|
|
"limit": limit,
|
|
"search": bool(search)
|
|
})
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"collection": collection_name,
|
|
"documents": docs,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + limit - 1) // limit
|
|
}
|
|
})
|
|
except Exception as e:
|
|
print(f"Error fetching collection documents: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/database/collections/<collection_name>", methods=["POST"])
|
|
@admin_required
|
|
def create_collection_document(collection_name):
|
|
"""Create a document in a selected collection."""
|
|
try:
|
|
data = _safe_json_payload()
|
|
if not isinstance(data, dict) or len(data) == 0:
|
|
return jsonify({"error": "Request body must be a non-empty JSON object"}), 400
|
|
|
|
result = db[collection_name].insert_one(data)
|
|
_admin_log("admin_data_create", "create_collection_document", status_code=201, metadata={"collection": collection_name})
|
|
return jsonify({"success": True, "id": str(result.inserted_id)}), 201
|
|
except Exception as e:
|
|
print(f"Error creating collection document: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/database/collections/<collection_name>/<doc_id>", methods=["PUT"])
|
|
@admin_required
|
|
def update_collection_document(collection_name, doc_id):
|
|
"""Update a document in selected collection."""
|
|
try:
|
|
oid = _to_object_id(doc_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid document id"}), 400
|
|
|
|
data = _safe_json_payload()
|
|
if not isinstance(data, dict) or len(data) == 0:
|
|
return jsonify({"error": "Request body must be a non-empty JSON object"}), 400
|
|
|
|
result = db[collection_name].update_one({"_id": oid}, {"$set": data})
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "Document not found"}), 404
|
|
|
|
_admin_log("admin_data_update", "update_collection_document", status_code=200, metadata={"collection": collection_name})
|
|
return jsonify({"success": True, "modified": result.modified_count})
|
|
except Exception as e:
|
|
print(f"Error updating collection document: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/database/collections/<collection_name>/<doc_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_collection_document(collection_name, doc_id):
|
|
"""Delete a document in selected collection."""
|
|
try:
|
|
oid = _to_object_id(doc_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid document id"}), 400
|
|
|
|
result = db[collection_name].delete_one({"_id": oid})
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "Document not found"}), 404
|
|
|
|
_admin_log("admin_data_delete", "delete_collection_document", status_code=200, metadata={"collection": collection_name})
|
|
return jsonify({"success": True})
|
|
except Exception as e:
|
|
print(f"Error deleting collection document: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users", methods=["POST"])
|
|
@admin_required
|
|
def create_user_admin():
|
|
"""Create user from admin panel."""
|
|
try:
|
|
data = _safe_json_payload()
|
|
email = (data.get("email") or "").strip().lower()
|
|
username = (data.get("username") or "").strip()
|
|
role = (data.get("role") or "student").strip().lower()
|
|
password = (data.get("password") or "ChangeMe123!").strip()
|
|
|
|
if not email:
|
|
return jsonify({"error": "Email is required"}), 400
|
|
|
|
if db.users.find_one({"email": email}):
|
|
return jsonify({"error": "Email already exists"}), 409
|
|
|
|
now = datetime.utcnow()
|
|
user_doc = {
|
|
"email": email,
|
|
"username": username or email.split("@")[0],
|
|
"name": data.get("name") or "",
|
|
"wallet_address": data.get("wallet_address") or "",
|
|
"auth_method": data.get("auth_method") or "email",
|
|
"role": role,
|
|
"status": data.get("status") or "active",
|
|
"password_hash": _hash_password(password),
|
|
"login_count": 0,
|
|
"created_at": now,
|
|
"last_login": now,
|
|
"progress": data.get("progress") or {"courses_completed": 0, "quizzes_completed": 0}
|
|
}
|
|
result = db.users.insert_one(user_doc)
|
|
_admin_log("admin_user_create", "create_user", status_code=201, metadata={"user_id": str(result.inserted_id), "email": email})
|
|
return jsonify({"success": True, "id": str(result.inserted_id)}), 201
|
|
except Exception as e:
|
|
print(f"Error creating user from admin: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users/<user_id>", methods=["PUT"])
|
|
@admin_required
|
|
def update_user_admin(user_id):
|
|
"""Update user profile, role and status from admin panel."""
|
|
try:
|
|
oid = _to_object_id(user_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid user id"}), 400
|
|
|
|
data = _safe_json_payload()
|
|
allowed = ["email", "username", "name", "wallet_address", "role", "status", "auth_method", "progress"]
|
|
update_data = {k: data[k] for k in allowed if k in data}
|
|
if "email" in update_data:
|
|
update_data["email"] = str(update_data["email"]).strip().lower()
|
|
if len(update_data) == 0:
|
|
return jsonify({"error": "No updatable fields provided"}), 400
|
|
|
|
result = db.users.update_one({"_id": oid}, {"$set": update_data})
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
_admin_log("admin_user_update", "update_user", status_code=200, metadata={"user_id": user_id})
|
|
return jsonify({"success": True, "modified": result.modified_count})
|
|
except Exception as e:
|
|
print(f"Error updating user from admin: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users/<user_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_user_admin(user_id):
|
|
"""Delete user from admin panel."""
|
|
try:
|
|
oid = _to_object_id(user_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid user id"}), 400
|
|
|
|
result = db.users.delete_one({"_id": oid})
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
_admin_log("admin_user_delete", "delete_user", status_code=200, metadata={"user_id": user_id})
|
|
return jsonify({"success": True})
|
|
except Exception as e:
|
|
print(f"Error deleting user from admin: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users/<user_id>/status", methods=["POST"])
|
|
@admin_required
|
|
def update_user_status_admin(user_id):
|
|
"""Ban/suspend/restrict/activate a user."""
|
|
try:
|
|
oid = _to_object_id(user_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid user id"}), 400
|
|
|
|
data = _safe_json_payload()
|
|
status = (data.get("status") or "").strip().lower()
|
|
allowed = {"active", "suspended", "banned", "restricted"}
|
|
if status not in allowed:
|
|
return jsonify({"error": "Invalid status"}), 400
|
|
|
|
user = db.users.find_one({"_id": oid})
|
|
if not user:
|
|
return jsonify({"error": "User not found"}), 404
|
|
previous_status = str(user.get("status") or "active").lower()
|
|
|
|
result = db.users.update_one({"_id": oid}, {"$set": {"status": status}})
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
target_user_id = user.get("wallet_address") or str(user.get("_id"))
|
|
log_user_activity(
|
|
db,
|
|
target_user_id,
|
|
"account_status",
|
|
f"Account status changed to {status}",
|
|
f"Admin updated account status from {previous_status} to {status}",
|
|
{
|
|
"previous_status": previous_status,
|
|
"new_status": status,
|
|
"updated_by": "admin",
|
|
},
|
|
)
|
|
|
|
_admin_log("admin_user_status", "update_user_status", status_code=200, metadata={"user_id": user_id, "status": status})
|
|
return jsonify({"success": True, "status": status})
|
|
except Exception as e:
|
|
print(f"Error updating user status: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users/<user_id>/role", methods=["POST"])
|
|
@admin_required
|
|
def update_user_role_admin(user_id):
|
|
"""Assign role to a user (student, instructor, admin)."""
|
|
try:
|
|
oid = _to_object_id(user_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid user id"}), 400
|
|
|
|
data = _safe_json_payload()
|
|
role = (data.get("role") or "").strip().lower()
|
|
allowed = {"student", "instructor", "admin"}
|
|
if role not in allowed:
|
|
return jsonify({"error": "Invalid role"}), 400
|
|
|
|
result = db.users.update_one({"_id": oid}, {"$set": {"role": role}})
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
_admin_log("admin_user_role", "update_user_role", status_code=200, metadata={"user_id": user_id, "role": role})
|
|
return jsonify({"success": True, "role": role})
|
|
except Exception as e:
|
|
print(f"Error updating user role: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/users/<user_id>/reset-password", methods=["POST"])
|
|
@admin_required
|
|
def reset_user_password_admin(user_id):
|
|
"""Reset user password manually from admin panel."""
|
|
try:
|
|
oid = _to_object_id(user_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid user id"}), 400
|
|
|
|
data = _safe_json_payload()
|
|
new_password = (data.get("new_password") or "TempPass123!").strip()
|
|
if len(new_password) < 6:
|
|
return jsonify({"error": "Password must be at least 6 characters"}), 400
|
|
|
|
result = db.users.update_one({"_id": oid}, {"$set": {"password_hash": _hash_password(new_password)}})
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
_admin_log("admin_user_password_reset", "reset_user_password", status_code=200, metadata={"user_id": user_id})
|
|
return jsonify({"success": True, "message": "Password reset successfully"})
|
|
except Exception as e:
|
|
print(f"Error resetting user password: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/recent-actions", methods=["GET"])
|
|
@admin_required
|
|
def get_recent_actions():
|
|
"""Get recent admin and security actions."""
|
|
try:
|
|
limit = min(max(int(request.args.get("limit", 20)), 1), 200)
|
|
docs = list(db.security_logs.find({}).sort("timestamp", DESCENDING).limit(limit))
|
|
actions = [_json_safe(d) for d in docs]
|
|
for item in actions:
|
|
item["id"] = str(item.get("_id", item.get("id", "")))
|
|
item.pop("_id", None)
|
|
return jsonify({"success": True, "actions": actions})
|
|
except Exception as e:
|
|
print(f"Error getting recent actions: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/alerts", methods=["GET"])
|
|
@admin_required
|
|
def get_security_alerts():
|
|
"""Get suspicious activity alerts for admin monitoring."""
|
|
try:
|
|
limit = min(max(int(request.args.get("limit", 50)), 1), 500)
|
|
query = {
|
|
"$or": [
|
|
{"event_type": "suspicious_payload"},
|
|
{"severity": "warning"},
|
|
{"severity": "error"},
|
|
{"status_code": {"$gte": 400}}
|
|
]
|
|
}
|
|
docs = list(db.security_logs.find(query).sort("timestamp", DESCENDING).limit(limit))
|
|
alerts = [_json_safe(d) for d in docs]
|
|
for item in alerts:
|
|
item["id"] = str(item.get("_id", item.get("id", "")))
|
|
item.pop("_id", None)
|
|
|
|
return jsonify({"success": True, "alerts": alerts, "count": len(alerts)})
|
|
except Exception as e:
|
|
print(f"Error getting alerts: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/analytics/activity", methods=["GET"])
|
|
@admin_required
|
|
def get_activity_analytics():
|
|
"""Provide graph-ready activity analytics for admin dashboard."""
|
|
try:
|
|
event_pipeline = [
|
|
{"$group": {"_id": "$event_type", "count": {"$sum": 1}}},
|
|
{"$sort": {"count": -1}}
|
|
]
|
|
status_pipeline = [
|
|
{"$group": {"_id": "$status_code", "count": {"$sum": 1}}},
|
|
{"$sort": {"_id": 1}}
|
|
]
|
|
method_pipeline = [
|
|
{"$group": {"_id": "$method", "count": {"$sum": 1}}},
|
|
{"$sort": {"count": -1}}
|
|
]
|
|
|
|
events = list(db.security_logs.aggregate(event_pipeline))
|
|
statuses = list(db.security_logs.aggregate(status_pipeline))
|
|
methods = list(db.security_logs.aggregate(method_pipeline))
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"charts": {
|
|
"events": [{"label": str(x.get("_id") or "unknown"), "value": int(x.get("count", 0))} for x in events],
|
|
"status_codes": [{"label": str(x.get("_id") or 0), "value": int(x.get("count", 0))} for x in statuses],
|
|
"methods": [{"label": str(x.get("_id") or "unknown"), "value": int(x.get("count", 0))} for x in methods]
|
|
}
|
|
})
|
|
except Exception as e:
|
|
print(f"Error getting analytics: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/reports/usage", methods=["GET"])
|
|
@admin_required
|
|
def get_usage_report():
|
|
"""Usage report for admin panel."""
|
|
try:
|
|
report = {
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
"users_total": db.users.count_documents({}),
|
|
"users_active": db.users.count_documents({"status": "active"}),
|
|
"students_total": db.users.count_documents({"role": "student"}),
|
|
"instructors_total": db.users.count_documents({"role": "instructor"}),
|
|
"courses_total": db.courses.count_documents({}),
|
|
"modules_total": db.modules.count_documents({}),
|
|
"lessons_total": db.lessons.count_documents({}),
|
|
"logs_total": db.security_logs.count_documents({})
|
|
}
|
|
return jsonify({"success": True, "report": report})
|
|
except Exception as e:
|
|
print(f"Error getting usage report: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/reports/security", methods=["GET"])
|
|
@admin_required
|
|
def get_security_report():
|
|
"""Security report including login attempts and suspicious activity."""
|
|
try:
|
|
login_attempts = db.security_logs.count_documents({"event_type": {"$in": ["signin", "signup", "admin_auth_failed", "admin_auth_success"]}})
|
|
suspicious_events = db.security_logs.count_documents({"event_type": "suspicious_payload"})
|
|
error_events = db.security_logs.count_documents({"status_code": {"$gte": 400}})
|
|
blocked_events = db.security_logs.count_documents({"event_type": "firewall_block"})
|
|
|
|
top_ip_pipeline = [
|
|
{"$group": {"_id": "$ip", "count": {"$sum": 1}}},
|
|
{"$sort": {"count": -1}},
|
|
{"$limit": 10}
|
|
]
|
|
top_ips = list(db.security_logs.aggregate(top_ip_pipeline))
|
|
|
|
report = {
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
"login_attempts": login_attempts,
|
|
"suspicious_events": suspicious_events,
|
|
"error_events": error_events,
|
|
"blocked_events": blocked_events,
|
|
"top_ips": [{"ip": str(x.get("_id") or "unknown"), "count": int(x.get("count", 0))} for x in top_ips]
|
|
}
|
|
return jsonify({"success": True, "report": report})
|
|
except Exception as e:
|
|
print(f"Error getting security report: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/reports/export", methods=["GET"])
|
|
@admin_required
|
|
def export_report_data():
|
|
"""Export usage/security/log reports as JSON or CSV."""
|
|
try:
|
|
report_type = (request.args.get("type") or "usage").strip().lower()
|
|
export_format = (request.args.get("format") or "json").strip().lower()
|
|
|
|
if report_type not in {"usage", "security", "logs"}:
|
|
return jsonify({"error": "Invalid report type"}), 400
|
|
if export_format not in {"json", "csv"}:
|
|
return jsonify({"error": "Invalid export format"}), 400
|
|
|
|
if report_type == "usage":
|
|
report = {
|
|
"users_total": db.users.count_documents({}),
|
|
"users_active": db.users.count_documents({"status": "active"}),
|
|
"students_total": db.users.count_documents({"role": "student"}),
|
|
"instructors_total": db.users.count_documents({"role": "instructor"}),
|
|
"courses_total": db.courses.count_documents({}),
|
|
"modules_total": db.modules.count_documents({}),
|
|
"lessons_total": db.lessons.count_documents({}),
|
|
"logs_total": db.security_logs.count_documents({})
|
|
}
|
|
elif report_type == "security":
|
|
report = {
|
|
"login_attempts": db.security_logs.count_documents({"event_type": {"$in": ["signin", "signup", "admin_auth_failed", "admin_auth_success"]}}),
|
|
"suspicious_events": db.security_logs.count_documents({"event_type": "suspicious_payload"}),
|
|
"error_events": db.security_logs.count_documents({"status_code": {"$gte": 400}}),
|
|
"blocked_events": db.security_logs.count_documents({"event_type": "firewall_block"})
|
|
}
|
|
else:
|
|
event_type = request.args.get("event_type", "").strip()
|
|
severity = request.args.get("severity", "").strip()
|
|
status_code = request.args.get("status_code", "").strip()
|
|
search = request.args.get("search", "").strip()
|
|
from_ts = request.args.get("from", "").strip()
|
|
to_ts = request.args.get("to", "").strip()
|
|
limit = min(max(int(request.args.get("limit", 1000)), 1), 10000)
|
|
|
|
query = {}
|
|
if event_type:
|
|
query["event_type"] = event_type
|
|
if severity:
|
|
query["severity"] = severity
|
|
if status_code:
|
|
try:
|
|
query["status_code"] = int(status_code)
|
|
except Exception:
|
|
pass
|
|
|
|
ts_filter = {}
|
|
if from_ts:
|
|
try:
|
|
ts_filter["$gte"] = datetime.fromisoformat(from_ts)
|
|
except Exception:
|
|
pass
|
|
if to_ts:
|
|
try:
|
|
ts_filter["$lte"] = datetime.fromisoformat(to_ts)
|
|
except Exception:
|
|
pass
|
|
if ts_filter:
|
|
query["timestamp"] = ts_filter
|
|
|
|
if search:
|
|
safe = re.escape(search)
|
|
query["$or"] = [
|
|
{"action": {"$regex": safe, "$options": "i"}},
|
|
{"path": {"$regex": safe, "$options": "i"}},
|
|
{"ip": {"$regex": safe, "$options": "i"}},
|
|
{"event_type": {"$regex": safe, "$options": "i"}},
|
|
{"metadata_text": {"$regex": safe, "$options": "i"}}
|
|
]
|
|
|
|
docs = list(db.security_logs.find(query).sort("timestamp", DESCENDING).limit(limit))
|
|
logs = []
|
|
for item in docs:
|
|
clean = _json_safe(item)
|
|
clean["id"] = str(clean.get("_id", clean.get("id", "")))
|
|
clean.pop("_id", None)
|
|
logs.append(clean)
|
|
|
|
report = {
|
|
"count": len(logs),
|
|
"logs": logs
|
|
}
|
|
|
|
payload = {
|
|
"success": True,
|
|
"report_type": report_type,
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
"data": report
|
|
}
|
|
|
|
if export_format == "json":
|
|
return jsonify(payload)
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
if report_type == "logs":
|
|
writer.writerow(["timestamp", "event_type", "action", "status_code", "severity", "method", "ip", "path", "user_agent", "metadata"])
|
|
for row in report.get("logs", []):
|
|
writer.writerow([
|
|
row.get("timestamp", ""),
|
|
row.get("event_type", ""),
|
|
row.get("action", ""),
|
|
row.get("status_code", ""),
|
|
row.get("severity", ""),
|
|
row.get("method", ""),
|
|
row.get("ip", ""),
|
|
row.get("path", ""),
|
|
row.get("user_agent", ""),
|
|
json.dumps(row.get("metadata", {}), ensure_ascii=True),
|
|
])
|
|
else:
|
|
writer.writerow(["key", "value"])
|
|
for k, v in report.items():
|
|
writer.writerow([k, v])
|
|
|
|
return jsonify({"success": True, "report_type": report_type, "format": "csv", "content": output.getvalue()})
|
|
except Exception as e:
|
|
print(f"Error exporting report data: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/firewall/rules", methods=["GET"])
|
|
@admin_required
|
|
def list_firewall_rules():
|
|
"""List manually managed firewall rules."""
|
|
try:
|
|
docs = list(db.firewall_rules.find({}).sort("created_at", DESCENDING))
|
|
rules = [_json_safe(d) for d in docs]
|
|
for item in rules:
|
|
item["id"] = str(item.get("_id", item.get("id", "")))
|
|
item.pop("_id", None)
|
|
return jsonify({"success": True, "rules": rules})
|
|
except Exception as e:
|
|
print(f"Error listing firewall rules: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/firewall/rules", methods=["POST"])
|
|
@admin_required
|
|
def create_firewall_rule():
|
|
"""Create manual firewall rule (add only when admin requests)."""
|
|
try:
|
|
data = _safe_json_payload()
|
|
rule = {
|
|
"name": (data.get("name") or f"rule-{uuid.uuid4().hex[:8]}").strip(),
|
|
"ip": (data.get("ip") or "").strip(),
|
|
"method": (data.get("method") or "").strip().upper(),
|
|
"path_pattern": (data.get("path_pattern") or "").strip(),
|
|
"action": (data.get("action") or "block").strip().lower(),
|
|
"enabled": bool(data.get("enabled", True)),
|
|
"created_at": datetime.utcnow(),
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
|
|
if rule["action"] not in {"block", "allow"}:
|
|
return jsonify({"error": "action must be block or allow"}), 400
|
|
if not rule["ip"] and not rule["path_pattern"]:
|
|
return jsonify({"error": "Provide ip or path_pattern"}), 400
|
|
|
|
result = db.firewall_rules.insert_one(rule)
|
|
_admin_log("firewall_rule_create", "create_firewall_rule", status_code=201, metadata={"rule_id": str(result.inserted_id)})
|
|
return jsonify({"success": True, "id": str(result.inserted_id)}), 201
|
|
except Exception as e:
|
|
print(f"Error creating firewall rule: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@bp.route("/firewall/rules/<rule_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_firewall_rule(rule_id):
|
|
"""Delete manual firewall rule."""
|
|
try:
|
|
oid = _to_object_id(rule_id)
|
|
if not oid:
|
|
return jsonify({"error": "Invalid rule id"}), 400
|
|
|
|
result = db.firewall_rules.delete_one({"_id": oid})
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "Rule not found"}), 404
|
|
|
|
_admin_log("firewall_rule_delete", "delete_firewall_rule", status_code=200, metadata={"rule_id": rule_id})
|
|
return jsonify({"success": True})
|
|
except Exception as e:
|
|
print(f"Error deleting firewall rule: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/courses", methods=["GET"])
|
|
@admin_required
|
|
def get_admin_courses():
|
|
"""Get all courses for admin management"""
|
|
try:
|
|
print("Fetching courses from database...")
|
|
courses = list(db.courses.find({}))
|
|
print(f"Found {len(courses)} courses")
|
|
|
|
def normalize_title(value):
|
|
return " ".join(str(value or "").lower().split())
|
|
|
|
def course_score(item):
|
|
score = 0
|
|
for field in ["subject", "difficulty", "mentor", "description", "video_url", "embed_url"]:
|
|
if item.get(field):
|
|
score += 1
|
|
if isinstance(item.get("modules"), list):
|
|
score += min(len(item.get("modules", [])), 5)
|
|
return score
|
|
|
|
for course in courses:
|
|
if not course.get("id") and "_id" in course:
|
|
course["id"] = str(course["_id"])
|
|
if "_id" in course:
|
|
del course["_id"]
|
|
if not course.get("title"):
|
|
course["title"] = course.get("name", "")
|
|
if not course.get("subject"):
|
|
course["subject"] = course.get("category", course.get("topic", ""))
|
|
if not course.get("difficulty"):
|
|
course["difficulty"] = course.get("level", "")
|
|
if not course.get("mentor"):
|
|
course["mentor"] = course.get("instructor", course.get("instructor_name", course.get("mentor_name", "")))
|
|
if not course.get("description"):
|
|
course["description"] = course.get("summary", "")
|
|
course["students"] = course.get("students", 0)
|
|
course["status"] = "published"
|
|
|
|
deduped = {}
|
|
for course in courses:
|
|
key = normalize_title(course.get("title")) or course.get("id")
|
|
existing = deduped.get(key)
|
|
if not existing or course_score(course) > course_score(existing):
|
|
deduped[key] = course
|
|
|
|
return jsonify(list(deduped.values()))
|
|
except Exception as e:
|
|
print(f"Error fetching courses: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/courses", methods=["POST"])
|
|
@admin_required
|
|
def create_course():
|
|
"""Create new course"""
|
|
try:
|
|
data = request.json
|
|
print(f"Creating course with data: {data}")
|
|
|
|
course_id = data.get('id') or f"{data.get('title', '').lower().replace(' ', '-').replace('&', 'and')}-course"
|
|
|
|
existing_course = db.courses.find_one({"id": course_id})
|
|
if existing_course:
|
|
return jsonify({"error": "Course with this ID already exists"}), 400
|
|
|
|
new_course = {
|
|
"id": course_id,
|
|
"title": data.get('title'),
|
|
"subject": data.get('subject'),
|
|
"description": data.get('description'),
|
|
"difficulty": data.get('difficulty'),
|
|
"mentor": data.get('mentor', '5t4l1n'),
|
|
"video_url": data.get('video_url'),
|
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"students": 0,
|
|
"progress": 0,
|
|
"modules": []
|
|
}
|
|
|
|
result = db.courses.insert_one(new_course)
|
|
print(f"Course created with ID: {result.inserted_id}")
|
|
|
|
# Remove _id field before returning
|
|
new_course_response = serialize_course(new_course)
|
|
|
|
return jsonify({"success": True, "course": new_course_response}), 201
|
|
|
|
except Exception as e:
|
|
print(f"Error creating course: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/courses/<course_id>", methods=["PUT"])
|
|
@admin_required
|
|
def update_course(course_id):
|
|
"""Update existing course"""
|
|
try:
|
|
data = request.json
|
|
print(f"Updating course {course_id} with data: {data}")
|
|
|
|
update_data = {
|
|
"title": data.get('title'),
|
|
"subject": data.get('subject'),
|
|
"description": data.get('description'),
|
|
"difficulty": data.get('difficulty'),
|
|
"mentor": data.get('mentor'),
|
|
"video_url": data.get('video_url'),
|
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
# Remove None values
|
|
update_data = {k: v for k, v in update_data.items() if v is not None}
|
|
print(f"Filtered update data: {update_data}")
|
|
|
|
result = db.courses.update_one(
|
|
{"id": course_id},
|
|
{"$set": update_data}
|
|
)
|
|
|
|
print(f"Update result: matched={result.matched_count}, modified={result.modified_count}")
|
|
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "Course not found"}), 404
|
|
|
|
# Get updated course without _id field
|
|
updated_course = db.courses.find_one({"id": course_id}, {"_id": 0})
|
|
return jsonify({"success": True, "course": updated_course})
|
|
|
|
except Exception as e:
|
|
print(f"Error updating course: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/courses/<course_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_course(course_id):
|
|
"""Delete course and all related modules and lessons"""
|
|
try:
|
|
print(f"Deleting course: {course_id}")
|
|
|
|
course = db.courses.find_one({"id": course_id})
|
|
if not course:
|
|
try:
|
|
course = db.courses.find_one({"_id": ObjectId(course_id)})
|
|
except Exception:
|
|
course = None
|
|
|
|
if not course:
|
|
return jsonify({"error": "Course not found"}), 404
|
|
|
|
course_key = course.get("id") or str(course.get("_id"))
|
|
|
|
# Delete related lessons first
|
|
lesson_result = db.lessons.delete_many({"course_id": course_key})
|
|
print(f"Deleted {lesson_result.deleted_count} related lessons")
|
|
|
|
# Delete related modules
|
|
module_result = db.modules.delete_many({"course_id": course_key})
|
|
print(f"Deleted {module_result.deleted_count} related modules")
|
|
|
|
result = db.courses.delete_one({"_id": course.get("_id")})
|
|
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "Course not found"}), 404
|
|
|
|
return jsonify({"success": True, "message": "Course deleted successfully"})
|
|
|
|
except Exception as e:
|
|
print(f"Error deleting course: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# ✅ FIXED: Module Management Endpoints (removed duplicates)
|
|
@bp.route("/courses/<course_id>/modules", methods=["GET"])
|
|
@admin_required
|
|
def get_course_modules(course_id):
|
|
"""Get all modules for a specific course"""
|
|
try:
|
|
print(f"Fetching modules for course: {course_id}")
|
|
|
|
modules = list(db.modules.find({"course_id": course_id}).sort("order", 1))
|
|
|
|
# Convert ObjectId to string
|
|
for module in modules:
|
|
if '_id' in module:
|
|
module['id'] = str(module['_id'])
|
|
del module['_id']
|
|
|
|
print(f"Found {len(modules)} modules for course {course_id}")
|
|
return jsonify({"success": True, "modules": modules})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching modules: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/courses/<course_id>/modules", methods=["POST"])
|
|
@admin_required
|
|
def create_module(course_id):
|
|
"""Create a new module for a course"""
|
|
try:
|
|
data = request.json
|
|
print(f"Creating module for course {course_id} with data: {data}")
|
|
|
|
# Verify course exists
|
|
course = db.courses.find_one({"id": course_id})
|
|
if not course:
|
|
return jsonify({"error": "Course not found"}), 404
|
|
|
|
module = {
|
|
"course_id": course_id,
|
|
"title": data.get('title'),
|
|
"description": data.get('description', ''),
|
|
"order": data.get('order', 1),
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
result = db.modules.insert_one(module)
|
|
module['id'] = str(result.inserted_id)
|
|
if '_id' in module:
|
|
del module['_id']
|
|
|
|
print(f"Module created with ID: {result.inserted_id}")
|
|
return jsonify({"success": True, "module": module}), 201
|
|
|
|
except Exception as e:
|
|
print(f"Error creating module: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/modules/<module_id>", methods=["GET"])
|
|
@admin_required
|
|
def get_module(module_id):
|
|
"""Get a specific module by ID"""
|
|
try:
|
|
print(f"Fetching module: {module_id}")
|
|
|
|
module = db.modules.find_one({"_id": ObjectId(module_id)})
|
|
|
|
if not module:
|
|
return jsonify({"error": "Module not found"}), 404
|
|
|
|
# Convert ObjectId to string
|
|
if '_id' in module:
|
|
module['id'] = str(module['_id'])
|
|
del module['_id']
|
|
|
|
return jsonify({"success": True, "module": module})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching module: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/modules/<module_id>", methods=["PUT"])
|
|
@admin_required
|
|
def update_module(module_id):
|
|
"""Update an existing module"""
|
|
try:
|
|
data = request.json
|
|
print(f"Updating module {module_id} with data: {data}")
|
|
|
|
update_data = {
|
|
"title": data.get('title'),
|
|
"description": data.get('description'),
|
|
"order": data.get('order'),
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
# Remove None values
|
|
update_data = {k: v for k, v in update_data.items() if v is not None}
|
|
|
|
result = db.modules.update_one(
|
|
{"_id": ObjectId(module_id)},
|
|
{"$set": update_data}
|
|
)
|
|
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "Module not found"}), 404
|
|
|
|
# Get updated module
|
|
updated_module = db.modules.find_one({"_id": ObjectId(module_id)})
|
|
if updated_module:
|
|
updated_module['id'] = str(updated_module['_id'])
|
|
del updated_module['_id']
|
|
|
|
return jsonify({"success": True, "module": updated_module})
|
|
|
|
except Exception as e:
|
|
print(f"Error updating module: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/modules/<module_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_module(module_id):
|
|
"""Delete a module and all its lessons"""
|
|
try:
|
|
print(f"Deleting module: {module_id}")
|
|
|
|
# Delete related lessons first
|
|
lesson_result = db.lessons.delete_many({"module_id": module_id})
|
|
print(f"Deleted {lesson_result.deleted_count} related lessons")
|
|
|
|
# Delete the module
|
|
result = db.modules.delete_one({"_id": ObjectId(module_id)})
|
|
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "Module not found"}), 404
|
|
|
|
return jsonify({"success": True, "message": "Module deleted successfully"})
|
|
|
|
except Exception as e:
|
|
print(f"Error deleting module: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# ✅ FIXED: Lesson Management Endpoints
|
|
@bp.route("/modules/<module_id>/lessons", methods=["GET"])
|
|
@admin_required
|
|
def get_module_lessons(module_id):
|
|
"""Get all lessons for a specific module"""
|
|
try:
|
|
print(f"Fetching lessons for module: {module_id}")
|
|
|
|
lessons = list(db.lessons.find({"module_id": module_id}).sort("order", 1))
|
|
|
|
# Convert ObjectId to string
|
|
for lesson in lessons:
|
|
if '_id' in lesson:
|
|
lesson['id'] = str(lesson['_id'])
|
|
del lesson['_id']
|
|
|
|
print(f"Found {len(lessons)} lessons for module {module_id}")
|
|
return jsonify({"success": True, "lessons": lessons})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching lessons: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/modules/<module_id>/lessons", methods=["POST"])
|
|
@admin_required
|
|
def create_lesson(module_id):
|
|
"""Create a new lesson for a module"""
|
|
try:
|
|
data = request.json
|
|
print(f"Creating lesson for module {module_id} with data: {data}")
|
|
|
|
# Verify module exists
|
|
module = db.modules.find_one({"_id": ObjectId(module_id)})
|
|
if not module:
|
|
return jsonify({"error": "Module not found"}), 404
|
|
|
|
lesson = {
|
|
"module_id": module_id,
|
|
"course_id": module.get('course_id'),
|
|
"title": data.get('title'),
|
|
"description": data.get('description', ''),
|
|
"video_url": data.get('video_url'),
|
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
|
"order": data.get('order', 1),
|
|
"duration": data.get('duration'),
|
|
"type": data.get('type', 'video'),
|
|
"content": data.get('content', ''),
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
result = db.lessons.insert_one(lesson)
|
|
lesson['id'] = str(result.inserted_id)
|
|
if '_id' in lesson:
|
|
del lesson['_id']
|
|
|
|
print(f"Lesson created with ID: {result.inserted_id}")
|
|
return jsonify({"success": True, "lesson": lesson}), 201
|
|
|
|
except Exception as e:
|
|
print(f"Error creating lesson: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/lessons/<lesson_id>", methods=["GET"])
|
|
@admin_required
|
|
def get_lesson(lesson_id):
|
|
"""Get a specific lesson by ID"""
|
|
try:
|
|
print(f"Fetching lesson: {lesson_id}")
|
|
|
|
lesson = db.lessons.find_one({"_id": ObjectId(lesson_id)})
|
|
|
|
if not lesson:
|
|
return jsonify({"error": "Lesson not found"}), 404
|
|
|
|
# Convert ObjectId to string
|
|
if '_id' in lesson:
|
|
lesson['id'] = str(lesson['_id'])
|
|
del lesson['_id']
|
|
|
|
return jsonify({"success": True, "lesson": lesson})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching lesson: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/lessons/<lesson_id>", methods=["PUT"])
|
|
@admin_required
|
|
def update_lesson(lesson_id):
|
|
"""Update an existing lesson"""
|
|
try:
|
|
data = request.json
|
|
print(f"Updating lesson {lesson_id} with data: {data}")
|
|
|
|
update_data = {
|
|
"title": data.get('title'),
|
|
"description": data.get('description'),
|
|
"video_url": data.get('video_url'),
|
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
|
"order": data.get('order'),
|
|
"duration": data.get('duration'),
|
|
"type": data.get('type'),
|
|
"content": data.get('content'),
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
# Remove None values
|
|
update_data = {k: v for k, v in update_data.items() if v is not None}
|
|
|
|
result = db.lessons.update_one(
|
|
{"_id": ObjectId(lesson_id)},
|
|
{"$set": update_data}
|
|
)
|
|
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "Lesson not found"}), 404
|
|
|
|
# Get updated lesson
|
|
updated_lesson = db.lessons.find_one({"_id": ObjectId(lesson_id)})
|
|
if updated_lesson:
|
|
updated_lesson['id'] = str(updated_lesson['_id'])
|
|
del updated_lesson['_id']
|
|
|
|
return jsonify({"success": True, "lesson": updated_lesson})
|
|
|
|
except Exception as e:
|
|
print(f"Error updating lesson: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/lessons/<lesson_id>", methods=["DELETE"])
|
|
@admin_required
|
|
def delete_lesson(lesson_id):
|
|
"""Delete a lesson"""
|
|
try:
|
|
print(f"Deleting lesson: {lesson_id}")
|
|
|
|
result = db.lessons.delete_one({"_id": ObjectId(lesson_id)})
|
|
|
|
if result.deleted_count == 0:
|
|
return jsonify({"error": "Lesson not found"}), 404
|
|
|
|
return jsonify({"success": True, "message": "Lesson deleted successfully"})
|
|
|
|
except Exception as e:
|
|
print(f"Error deleting lesson: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# ✅ LEGACY: For backward compatibility with old course structure
|
|
@bp.route("/courses/<course_id>/lessons", methods=["POST"])
|
|
@admin_required
|
|
def add_lesson_legacy(course_id):
|
|
"""Add lesson to course (legacy endpoint)"""
|
|
try:
|
|
data = request.json
|
|
|
|
lesson = {
|
|
"id": data.get('id') or str(uuid.uuid4()),
|
|
"course_id": course_id,
|
|
"title": data.get('title'),
|
|
"type": data.get('type', 'video'),
|
|
"duration": data.get('duration'),
|
|
"description": data.get('description'),
|
|
"content": data.get('content'),
|
|
"video_url": data.get('video_url'),
|
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
|
"created_at": datetime.now().isoformat()
|
|
}
|
|
|
|
# Insert lesson
|
|
db.lessons.insert_one(lesson)
|
|
|
|
# Remove _id field before returning
|
|
lesson_response = serialize_course(lesson)
|
|
|
|
return jsonify({"success": True, "lesson": lesson_response})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/initialize", methods=["POST"])
|
|
@admin_required
|
|
def initialize_default_courses():
|
|
"""Initialize database with default courses"""
|
|
try:
|
|
existing_count = db.courses.count_documents({})
|
|
if existing_count > 0:
|
|
return jsonify({"message": f"Courses already initialized ({existing_count} courses found)"}), 200
|
|
|
|
default_courses = [
|
|
{
|
|
"id": "python-course",
|
|
"title": "Python Programming Mastery",
|
|
"subject": "Programming",
|
|
"description": "Learn Python from basics to advanced concepts including turtle graphics",
|
|
"difficulty": "Beginner to Advanced",
|
|
"mentor": "5t4l1n",
|
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
|
"embed_url": "https://www.youtube.com/embed/SsH8GJlqUIg?rel=0&modestbranding=1",
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"students": 1250,
|
|
"progress": 0,
|
|
"modules": []
|
|
},
|
|
{
|
|
"id": "java-course",
|
|
"title": "Java Development Bootcamp",
|
|
"subject": "Programming",
|
|
"description": "Master Java programming with object-oriented concepts",
|
|
"difficulty": "Intermediate",
|
|
"mentor": "5t4l1n",
|
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
|
"embed_url": "https://www.youtube.com/embed/SsH8GJlqUIg?rel=0&modestbranding=1",
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"students": 890,
|
|
"progress": 0,
|
|
"modules": []
|
|
},
|
|
{
|
|
"id": "ethical-hacking-course",
|
|
"title": "Ethical Hacking & Cybersecurity",
|
|
"subject": "Cybersecurity",
|
|
"description": "Learn ethical hacking techniques and penetration testing",
|
|
"difficulty": "Advanced",
|
|
"mentor": "5t4l1n",
|
|
"video_url": "https://youtu.be/cDnX0vyNTaE?si=ZXNI4hv2HlWN7eCS",
|
|
"embed_url": "https://www.youtube.com/embed/cDnX0vyNTaE?rel=0&modestbranding=1",
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"students": 567,
|
|
"progress": 0,
|
|
"modules": []
|
|
},
|
|
{
|
|
"id": "dark-web-hosting-course",
|
|
"title": "Learn Dark Web Hosting",
|
|
"subject": "Cybersecurity",
|
|
"description": "Understanding dark web infrastructure, Tor networks, and secure hosting practices for cybersecurity professionals",
|
|
"difficulty": "Expert",
|
|
"mentor": "5t4l1n",
|
|
"video_url": "https://youtu.be/Z4_USAMVhYs?si=Y_ThVisph5ekM44U",
|
|
"embed_url": "https://www.youtube.com/embed/Z4_USAMVhYs?rel=0&modestbranding=1",
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"students": 234,
|
|
"progress": 0,
|
|
"modules": []
|
|
}
|
|
]
|
|
|
|
result = db.courses.insert_many(default_courses)
|
|
print(f"Initialized {len(result.inserted_ids)} default courses")
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"message": f"Default courses initialized successfully",
|
|
"courses_created": len(result.inserted_ids)
|
|
})
|
|
except Exception as e:
|
|
print(f"Error initializing courses: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/stats", methods=["GET"])
|
|
@admin_required
|
|
def get_admin_stats():
|
|
"""Get detailed admin statistics"""
|
|
try:
|
|
total_courses = db.courses.count_documents({})
|
|
|
|
# ✅ FIXED: Count modules and lessons from embedded course structure
|
|
total_modules = 0
|
|
total_lessons = 0
|
|
courses = list(db.courses.find({}, {"modules": 1}))
|
|
for course in courses:
|
|
modules = course.get("modules", [])
|
|
total_modules += len(modules)
|
|
for module in modules:
|
|
lessons = module.get("lessons", [])
|
|
total_lessons += len(lessons)
|
|
|
|
# Fallback to separate collections if they exist
|
|
if total_modules == 0:
|
|
total_modules = db.modules.count_documents({})
|
|
if total_lessons == 0:
|
|
total_lessons = db.lessons.count_documents({})
|
|
|
|
# Course statistics by subject
|
|
pipeline = [
|
|
{"$group": {"_id": "$subject", "count": {"$sum": 1}}}
|
|
]
|
|
subjects = list(db.courses.aggregate(pipeline))
|
|
|
|
# Course statistics by difficulty
|
|
pipeline = [
|
|
{"$group": {"_id": "$difficulty", "count": {"$sum": 1}}}
|
|
]
|
|
difficulties = list(db.courses.aggregate(pipeline))
|
|
|
|
stats = {
|
|
"total_courses": total_courses,
|
|
"total_lessons": total_lessons,
|
|
"total_modules": total_modules,
|
|
"subjects": subjects,
|
|
"difficulties": difficulties,
|
|
"last_updated": datetime.now().isoformat()
|
|
}
|
|
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
print(f"Error getting stats: {str(e)}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route("/health", methods=["GET"])
|
|
def admin_health():
|
|
"""Admin health check endpoint"""
|
|
return jsonify({
|
|
"status": "Admin API is healthy",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"database_connected": True,
|
|
"endpoints": [
|
|
"GET /api/admin/dashboard",
|
|
"GET /api/admin/courses",
|
|
"POST /api/admin/courses",
|
|
"PUT /api/admin/courses/<id>",
|
|
"DELETE /api/admin/courses/<id>",
|
|
"GET /api/admin/courses/<course_id>/modules",
|
|
"POST /api/admin/courses/<course_id>/modules",
|
|
"GET /api/admin/modules/<module_id>",
|
|
"PUT /api/admin/modules/<module_id>",
|
|
"DELETE /api/admin/modules/<module_id>",
|
|
"GET /api/admin/modules/<module_id>/lessons",
|
|
"POST /api/admin/modules/<module_id>/lessons",
|
|
"GET /api/admin/lessons/<lesson_id>",
|
|
"PUT /api/admin/lessons/<lesson_id>",
|
|
"DELETE /api/admin/lessons/<lesson_id>",
|
|
"POST /api/admin/initialize",
|
|
"GET /api/admin/test",
|
|
"GET /api/admin/stats"
|
|
]
|
|
})
|