mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 11:25:49 +00:00
473 lines
20 KiB
Python
473 lines
20 KiB
Python
from flask import Blueprint, request, jsonify
|
|
from datetime import datetime
|
|
import os
|
|
import uuid
|
|
import time
|
|
import secrets
|
|
import string
|
|
import logging
|
|
from bson import ObjectId
|
|
from pymongo import MongoClient
|
|
|
|
# Import your certificate manager
|
|
from models.certificate import CertificateManager
|
|
|
|
bp = Blueprint('certificates', __name__)
|
|
cert_manager = CertificateManager()
|
|
|
|
# Set up logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ✅ FIXED: Database connection function
|
|
def get_db():
|
|
"""Get MongoDB database connection"""
|
|
try:
|
|
client = MongoClient(os.getenv('MONGODB_URI', 'mongodb://localhost:27017/'))
|
|
db = client.openlearnx
|
|
# Test the connection
|
|
db.command('ismaster')
|
|
return db
|
|
except Exception as e:
|
|
logger.error(f"Database connection failed: {e}")
|
|
return None
|
|
|
|
@bp.route("/certificates", methods=["POST", "OPTIONS"])
|
|
def create_certificate():
|
|
"""Create a new certificate with GUARANTEED unique ID and fixed student name handling"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
data = request.json
|
|
logger.info(f"📝 Certificate creation request: {data}")
|
|
|
|
# Validate required fields
|
|
required_fields = ['user_name', 'course_id', 'wallet_id', 'user_id']
|
|
for field in required_fields:
|
|
if not data.get(field):
|
|
logger.error(f"❌ Missing required field: {field}")
|
|
return jsonify({"error": f"Missing required field: {field}"}), 400
|
|
|
|
# ✅ CRITICAL FIX: Get the STUDENT's entered name (exactly as they typed it)
|
|
student_entered_name = data.get('user_name', '').strip()
|
|
if not student_entered_name:
|
|
logger.error("❌ Student name cannot be empty")
|
|
return jsonify({"error": "Student name is required"}), 400
|
|
|
|
logger.info(f"🎓 Processing certificate for STUDENT: '{student_entered_name}'")
|
|
|
|
# Get database connection
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
# ✅ Check if certificate already exists for this user and course
|
|
existing_certificate = db.certificates.find_one({
|
|
"user_id": data['user_id'],
|
|
"course_id": data['course_id']
|
|
})
|
|
|
|
if existing_certificate is not None:
|
|
logger.info(f"📜 Certificate already exists for STUDENT: '{student_entered_name}'")
|
|
return jsonify({
|
|
"success": True,
|
|
"certificate": {
|
|
"certificate_id": existing_certificate['certificate_id'],
|
|
"user_name": student_entered_name, # ✅ FORCE RETURN STUDENT'S ENTERED NAME
|
|
"course_title": existing_certificate['course_title'],
|
|
"mentor_name": existing_certificate.get('mentor_name', existing_certificate.get('course_mentor', 'OpenLearnX Instructor')),
|
|
"completion_date": existing_certificate['completion_date'],
|
|
"share_code": existing_certificate.get('share_code'),
|
|
"public_url": existing_certificate.get('public_url'),
|
|
"unique_url": f"/certificate/{existing_certificate.get('share_code', existing_certificate['certificate_id'])}",
|
|
"message": "Certificate already exists!"
|
|
}
|
|
}), 200
|
|
|
|
# Check if course exists
|
|
try:
|
|
course = db.courses.find_one({"id": data['course_id']})
|
|
if course is None:
|
|
return jsonify({"error": "Course not found"}), 404
|
|
except Exception as e:
|
|
logger.error(f"❌ Error finding course: {e}")
|
|
return jsonify({"error": "Failed to verify course"}), 500
|
|
|
|
# ✅ CRITICAL FIX: GUARANTEED UNIQUE ID GENERATION
|
|
certificate_id = None
|
|
share_code = None
|
|
max_attempts = 50
|
|
|
|
for attempt in range(max_attempts):
|
|
# Generate new IDs using enhanced method
|
|
temp_cert_id = generate_unique_certificate_id()
|
|
temp_share_code = generate_unique_share_code()
|
|
|
|
logger.info(f"🆔 Attempt {attempt + 1}: Generated cert_id={temp_cert_id}, share_code={temp_share_code}")
|
|
|
|
# Check if IDs already exist in database
|
|
existing_cert_id = db.certificates.find_one({"certificate_id": temp_cert_id})
|
|
existing_share_code = db.certificates.find_one({"share_code": temp_share_code})
|
|
|
|
if not existing_cert_id and not existing_share_code:
|
|
certificate_id = temp_cert_id
|
|
share_code = temp_share_code
|
|
logger.info(f"✅ UNIQUE IDs confirmed: cert_id={certificate_id}, share_code={share_code}")
|
|
break
|
|
else:
|
|
logger.warning(f"⚠️ ID collision detected on attempt {attempt + 1}")
|
|
time.sleep(0.001) # Small delay to ensure timestamp changes
|
|
|
|
if not certificate_id or not share_code:
|
|
logger.error(f"❌ Failed to generate unique IDs after {max_attempts} attempts")
|
|
return jsonify({"error": "Failed to generate unique certificate ID"}), 500
|
|
|
|
# Generate token ID
|
|
token_id = str(uuid.uuid4())
|
|
|
|
# Encrypt wallet ID
|
|
encrypted_wallet = cert_manager.encrypt_wallet_id(data['wallet_id'])
|
|
if not encrypted_wallet:
|
|
return jsonify({"error": "Failed to encrypt wallet ID"}), 500
|
|
|
|
# ✅ CRITICAL FIX: Extract INSTRUCTOR name from course (separate from student)
|
|
instructor_name = course.get('mentor', 'OpenLearnX Instructor')
|
|
if isinstance(instructor_name, dict):
|
|
instructor_name = instructor_name.get('name', 'OpenLearnX Instructor')
|
|
|
|
# ✅ PREVENT STUDENT NAME FROM BEING USED AS INSTRUCTOR NAME
|
|
if instructor_name == student_entered_name:
|
|
instructor_name = 'OpenLearnX Instructor'
|
|
|
|
logger.info(f"🎓 FINAL VERIFICATION - STUDENT: '{student_entered_name}' | INSTRUCTOR: '{instructor_name}'")
|
|
|
|
# ✅ Create certificate document with GUARANTEED UNIQUE IDs and proper field separation
|
|
certificate = {
|
|
"certificate_id": certificate_id, # ✅ GUARANTEED UNIQUE
|
|
"token_id": token_id,
|
|
"share_code": share_code, # ✅ GUARANTEED UNIQUE
|
|
"student_name": student_entered_name, # ✅ EXPLICIT STUDENT FIELD
|
|
"user_name": student_entered_name, # ✅ STUDENT'S ENTERED NAME
|
|
"user_id": data['user_id'],
|
|
"course_id": data['course_id'],
|
|
"course_title": course['title'],
|
|
"mentor_name": instructor_name, # ✅ INSTRUCTOR NAME
|
|
"instructor_name": instructor_name, # ✅ EXPLICIT INSTRUCTOR FIELD
|
|
"course_mentor": instructor_name, # ✅ BACKWARD COMPATIBILITY
|
|
"encrypted_wallet_id": encrypted_wallet,
|
|
"completion_date": datetime.now().isoformat(),
|
|
"created_at": datetime.now().isoformat(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
"status": "active",
|
|
"issued_by": "OpenLearnX",
|
|
"verification_url": f"/certificates/{certificate_id}",
|
|
"share_url": f"/certificate/{share_code}",
|
|
"public_url": f"{request.host_url}certificate/{share_code}",
|
|
"blockchain_hash": None,
|
|
"is_revoked": False,
|
|
"view_count": 0,
|
|
"shared_count": 0
|
|
}
|
|
|
|
# ✅ Save to MongoDB with enhanced error handling
|
|
try:
|
|
# Create indexes for uniqueness
|
|
db.certificates.create_index([("certificate_id", 1)], unique=True, background=True)
|
|
db.certificates.create_index([("share_code", 1)], unique=True, background=True)
|
|
|
|
result = db.certificates.insert_one(certificate)
|
|
logger.info(f"✅ Certificate saved successfully for STUDENT: '{student_entered_name}' with unique ID: {certificate_id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Database save error: {e}")
|
|
return jsonify({"error": "Failed to save certificate to database"}), 500
|
|
|
|
# ✅ Return response with GUARANTEED STUDENT NAME and UNIQUE IDs
|
|
certificate_response = {
|
|
"certificate_id": certificate_id, # ✅ GUARANTEED UNIQUE ID
|
|
"token_id": token_id,
|
|
"share_code": share_code, # ✅ GUARANTEED UNIQUE SHARE CODE
|
|
"user_name": student_entered_name, # ✅ STUDENT'S ENTERED NAME (GUARANTEED)
|
|
"student_name": student_entered_name, # ✅ EXPLICIT STUDENT NAME
|
|
"course_title": course['title'],
|
|
"mentor_name": instructor_name, # ✅ INSTRUCTOR NAME
|
|
"instructor_name": instructor_name, # ✅ EXPLICIT INSTRUCTOR NAME
|
|
"course_mentor": instructor_name, # ✅ BACKWARD COMPATIBILITY
|
|
"completion_date": certificate['completion_date'],
|
|
"verification_url": certificate['verification_url'],
|
|
"share_url": certificate['share_url'],
|
|
"public_url": certificate['public_url'],
|
|
"unique_url": f"/certificate/{share_code}",
|
|
"message": f"Certificate with UNIQUE ID {certificate_id} generated successfully for {student_entered_name}!"
|
|
}
|
|
|
|
logger.info(f"📤 RETURNING CERTIFICATE with unique ID: {certificate_id} for STUDENT: {student_entered_name}")
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"certificate": certificate_response
|
|
}), 201
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error creating certificate: {str(e)}")
|
|
return jsonify({"error": "Failed to create certificate"}), 500
|
|
|
|
@bp.route("/certificates/<certificate_id>", methods=["GET", "OPTIONS"])
|
|
def get_certificate(certificate_id):
|
|
"""Get certificate by ID with proper field mapping"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
certificate = db.certificates.find_one({"certificate_id": certificate_id})
|
|
|
|
if not certificate:
|
|
return jsonify({"error": "Certificate not found"}), 404
|
|
|
|
# Check if certificate is revoked
|
|
if certificate.get('is_revoked', False):
|
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
|
|
|
# ✅ Decrypt wallet ID for display
|
|
decrypted_wallet = None
|
|
if certificate.get('encrypted_wallet_id'):
|
|
decrypted_wallet = cert_manager.decrypt_wallet_id(certificate['encrypted_wallet_id'])
|
|
|
|
# ✅ Prepare response with proper field mapping (prioritize explicit fields)
|
|
certificate_response = {
|
|
"certificate_id": certificate['certificate_id'],
|
|
"token_id": certificate.get('token_id'),
|
|
"share_code": certificate.get('share_code'),
|
|
# ✅ STUDENT NAME (prioritize explicit student_name field)
|
|
"user_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
|
# ✅ COURSE INFO
|
|
"course_title": certificate['course_title'],
|
|
# ✅ INSTRUCTOR NAME (prioritize explicit instructor_name field)
|
|
"mentor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
|
"course_mentor": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
|
# ✅ OTHER INFO
|
|
"completion_date": certificate['completion_date'],
|
|
"status": certificate['status'],
|
|
"wallet_id": decrypted_wallet,
|
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
|
"verification_url": certificate.get('verification_url'),
|
|
"share_url": certificate.get('share_url'),
|
|
"public_url": certificate.get('public_url'),
|
|
"unique_url": f"/certificate/{certificate.get('share_code', certificate_id)}",
|
|
"view_count": certificate.get('view_count', 0),
|
|
"blockchain_hash": certificate.get('blockchain_hash'),
|
|
"is_verified": True,
|
|
"is_revoked": certificate.get('is_revoked', False)
|
|
}
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"certificate": certificate_response
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching certificate: {str(e)}")
|
|
return jsonify({"error": "Failed to fetch certificate"}), 500
|
|
|
|
# ✅ UNIQUE CERTIFICATE VIEW ENDPOINT
|
|
@bp.route("/certificate/<share_code>", methods=["GET", "OPTIONS"])
|
|
def view_certificate_by_code(share_code):
|
|
"""View certificate by unique share code"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
# Find certificate by share code
|
|
certificate = db.certificates.find_one({"share_code": share_code})
|
|
|
|
if certificate is None:
|
|
return jsonify({"error": "Certificate not found"}), 404
|
|
|
|
# Check if certificate is revoked
|
|
if certificate.get('is_revoked', False):
|
|
return jsonify({"error": "Certificate has been revoked"}), 410
|
|
|
|
# ✅ INCREMENT VIEW COUNT
|
|
db.certificates.update_one(
|
|
{"share_code": share_code},
|
|
{"$inc": {"view_count": 1}}
|
|
)
|
|
|
|
# Decrypt wallet ID for display
|
|
decrypted_wallet = None
|
|
if certificate.get('encrypted_wallet_id') is not None:
|
|
decrypted_wallet = cert_manager.decrypt_wallet_id(certificate['encrypted_wallet_id'])
|
|
|
|
# ✅ PREPARE RESPONSE WITH GUARANTEED STUDENT NAME
|
|
certificate_response = {
|
|
"certificate_id": certificate['certificate_id'],
|
|
"share_code": certificate['share_code'],
|
|
"user_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
|
"student_name": certificate.get('student_name', certificate.get('user_name', 'Student')),
|
|
"course_title": certificate['course_title'],
|
|
"mentor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
|
"instructor_name": certificate.get('instructor_name', certificate.get('mentor_name', certificate.get('course_mentor', 'OpenLearnX Instructor'))),
|
|
"completion_date": certificate['completion_date'],
|
|
"status": certificate['status'],
|
|
"wallet_id": decrypted_wallet,
|
|
"issued_by": certificate.get('issued_by', 'OpenLearnX'),
|
|
"verification_url": certificate.get('verification_url'),
|
|
"share_url": certificate.get('share_url'),
|
|
"public_url": certificate.get('public_url'),
|
|
"view_count": certificate.get('view_count', 0),
|
|
"is_verified": True,
|
|
"is_revoked": certificate.get('is_revoked', False)
|
|
}
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"certificate": certificate_response
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching certificate by code: {str(e)}")
|
|
return jsonify({"error": "Failed to fetch certificate"}), 500
|
|
|
|
@bp.route("/certificates/user/<user_id>", methods=["GET", "OPTIONS"])
|
|
def get_user_certificates(user_id):
|
|
"""Get all certificates for a user"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
certificates = list(db.certificates.find(
|
|
{"user_id": user_id},
|
|
{"_id": 0, "encrypted_wallet_id": 0}
|
|
))
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"certificates": certificates,
|
|
"count": len(certificates)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching user certificates: {str(e)}")
|
|
return jsonify({"error": "Failed to fetch certificates"}), 500
|
|
|
|
# ✅ SHARE TRACKING ENDPOINT
|
|
@bp.route("/certificates/<certificate_id>/share", methods=["POST", "OPTIONS"])
|
|
def track_certificate_share(certificate_id):
|
|
"""Track certificate sharing"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
# Increment share count
|
|
result = db.certificates.update_one(
|
|
{"certificate_id": certificate_id},
|
|
{"$inc": {"shared_count": 1}}
|
|
)
|
|
|
|
if result.matched_count == 0:
|
|
return jsonify({"error": "Certificate not found"}), 404
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"message": "Share tracked successfully"
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error tracking share: {str(e)}")
|
|
return jsonify({"error": "Failed to track share"}), 500
|
|
|
|
@bp.route("/admin/certificates", methods=["GET", "OPTIONS"])
|
|
def get_all_certificates():
|
|
"""Admin endpoint to get all certificates"""
|
|
if request.method == "OPTIONS":
|
|
return jsonify({'status': 'ok'})
|
|
|
|
try:
|
|
# Check admin authentication
|
|
auth_header = request.headers.get('Authorization')
|
|
if auth_header is None or not auth_header.startswith('Bearer '):
|
|
return jsonify({"error": "Unauthorized"}), 401
|
|
|
|
token = auth_header.split(' ')[1]
|
|
expected_token = os.getenv('ADMIN_TOKEN', 'admin-secret-key')
|
|
|
|
if token != expected_token:
|
|
return jsonify({"error": "Invalid admin token"}), 401
|
|
|
|
db = get_db()
|
|
if db is None:
|
|
return jsonify({"error": "Database connection failed"}), 500
|
|
|
|
# Add pagination
|
|
page = int(request.args.get('page', 1))
|
|
limit = int(request.args.get('limit', 10))
|
|
skip = (page - 1) * limit
|
|
|
|
certificates = list(db.certificates.find(
|
|
{},
|
|
{"_id": 0, "encrypted_wallet_id": 0}
|
|
).skip(skip).limit(limit).sort("created_at", -1))
|
|
|
|
total = db.certificates.count_documents({})
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"certificates": certificates,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + limit - 1) // limit
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching certificates: {str(e)}")
|
|
return jsonify({"error": "Failed to fetch certificates"}), 500
|
|
|
|
# ✅ HELPER FUNCTIONS FOR UNIQUE ID GENERATION
|
|
def generate_unique_certificate_id():
|
|
"""Generate truly unique certificate ID with multiple randomness sources"""
|
|
# High-precision timestamp (microseconds)
|
|
timestamp_micro = str(int(time.time() * 1000000))[-8:]
|
|
|
|
# Cryptographically secure random
|
|
crypto_random = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
|
|
|
# Combined for uniqueness
|
|
certificate_id = (timestamp_micro + crypto_random)[:12]
|
|
|
|
# Ensure exactly 12 characters
|
|
if len(certificate_id) < 12:
|
|
certificate_id += ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(12 - len(certificate_id)))
|
|
|
|
return certificate_id
|
|
|
|
def generate_unique_share_code():
|
|
"""Generate unique share code with timestamp"""
|
|
# High precision timestamp
|
|
timestamp = str(int(time.time_ns()))[-4:]
|
|
|
|
# Cryptographically secure random
|
|
crypto_random = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4))
|
|
|
|
return timestamp + crypto_random
|