""" Attendr - Smart Attendance System Main Flask Application """ from flask import Flask, render_template, request, jsonify, session, redirect, url_for, flash, send_from_directory from werkzeug.middleware.proxy_fix import ProxyFix from flask_sqlalchemy import SQLAlchemy from config import Config from models import db, User, Student, Classroom, AttendanceSession, AttendanceRecord from face_recognition_module import face_recognition_module from geolocation_module import geolocation_module from attendance_code_module import attendance_code_module from utils import base64_to_image, image_to_numpy, format_error_response, format_success_response from auth import login_required, role_required, get_current_user from datetime import datetime import logging # Initialize Flask app app = Flask(__name__, template_folder='.') app.config.from_object(Config) Config.init_app(app) # Trust proxy headers (needed for Hugging Face Spaces) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) # Initialize database db.init_app(app) # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) def migrate_database(): """Manually add missing columns to the database if they don't exist""" try: from sqlalchemy import text with db.engine.connect() as conn: # Check if status column exists in users table result = conn.execute(text("PRAGMA table_info(users)")) columns = [row[1] for row in result] if 'status' not in columns: logger.info("Adding 'status' column to users table...") conn.execute(text("ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'pending'")) conn.commit() logger.info("Column 'status' added successfully.") except Exception as e: logger.error(f"Migration error: {str(e)}") # Ensure database tables are created (important for Hugging Face deployment) with app.app_context(): db.create_all() migrate_database() # Auto-initialize Admin user try: admin = User.query.filter_by(role='admin').first() if not admin: admin = User( matric_no='ADMIN', name='System Admin', email='admin@attendr.com', role='admin', is_active=True, status='approved' ) admin.set_password('admin123') # Default password db.session.add(admin) db.session.commit() logger.info("Default Admin user created (ADMIN / admin123)") except Exception as init_err: logger.error(f"Admin initialization error: {str(init_err)}") logger.info("Database initialized") # Force fix any users who are pending but accidentally active try: inconsistent_users = User.query.filter_by(status='pending', is_active=True).all() if inconsistent_users: for u in inconsistent_users: u.is_active = False db.session.commit() logger.info(f"Fixed {len(inconsistent_users)} inconsistent pending accounts.") except Exception as e: logger.error(f"Cleanup error: {str(e)}") @app.after_request def add_header(response): """ Add headers to prevent caching of dynamic pages. This ensures that when a user logs out, they cannot click 'Back' to see protected content from the browser cache. """ if 'Cache-Control' not in response.headers: response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '-1' return response # ==================== ROUTES ==================== @app.route('/style.css') def style(): return send_from_directory('.', 'style.css') @app.route('/') def index(): """Landing page""" current_user = get_current_user() return render_template('index.html', current_user=current_user) @app.route('/student') @login_required @role_required('student') def student_page(): """Student attendance interface - requires student login""" return render_template('student.html', current_user=get_current_user()) @app.route('/student/history') @login_required @role_required('student') def student_history(): """Student attendance history page""" return render_template('student_history.html', current_user=get_current_user()) @app.route('/lecturer') @login_required @role_required('lecturer') def lecturer_page(): """Lecturer dashboard - requires lecturer login""" return render_template('lecturer.html', current_user=get_current_user()) @app.route('/lecturer/history') @login_required @role_required('lecturer') def lecturer_history(): """Lecturer session history page""" return render_template('lecturer_history.html', current_user=get_current_user()) @app.route('/admin') @login_required @role_required('admin') def admin_page(): """Admin dashboard - approve/reject users""" return render_template('admin.html', current_user=get_current_user()) @app.route('/register') def register_page(): """User registration page - redirect if already logged in""" current_user = get_current_user() if current_user: role = current_user.role if role == 'admin': return redirect(url_for('admin_page')) return redirect(url_for('student_page' if role == 'student' else 'lecturer_page')) # Clear stale session if user doesn't exist if 'user_id' in session: session.clear() return render_template('register.html') @app.route('/login') def login_page(): """Login page - redirect if already logged in""" current_user = get_current_user() if current_user: role = current_user.role if role == 'admin': return redirect(url_for('admin_page')) return redirect(url_for('student_page' if role == 'student' else 'lecturer_page')) # Clear stale session if user doesn't exist if 'user_id' in session: session.clear() return render_template('login.html') # ==================== AUTHENTICATION API ==================== @app.route('/api/auth/register', methods=['POST']) def register_user(): """ Register new user (student or lecturer) Validates matric number format based on role """ try: data = request.get_json() required_fields = ['matric_no', 'name', 'password', 'role'] if not all(k in data for k in required_fields): return format_error_response(f"Missing required fields: {', '.join(required_fields)}") matric_no = data['matric_no'].upper().strip() name = data['name'] password = data['password'] role = data['role'].lower() email = data.get('email') # Validate role if role not in ['student', 'lecturer']: return format_error_response("Role must be 'student' or 'lecturer'") # Validate matric number format is_valid, error_msg = User.validate_matric_no(matric_no, role) if not is_valid: return format_error_response(error_msg, 400) # Check if user already exists existing_user = User.query.filter_by(matric_no=matric_no).first() if existing_user: return format_error_response("Matric number already registered", 409) if email: existing_email = User.query.filter_by(email=email).first() if existing_email: return format_error_response("Email already registered", 409) # Create user - explicitly set status and activity for Admin approval workflow user = User( matric_no=matric_no, name=name, email=email, role=role, is_active=False, status='pending' ) user.set_password(password) db.session.add(user) db.session.commit() logger.info(f"User registered: {matric_no} ({role})") return format_success_response( data=user.to_dict(), message=f"{role.capitalize()} account created successfully! Please wait for Admin approval before logging in." ) except Exception as e: db.session.rollback() logger.error(f"User registration error: {str(e)}") return format_error_response(f"Registration failed: {str(e)}", 500) @app.route('/api/auth/login', methods=['POST']) def login(): """Login user and create session""" try: data = request.get_json() if not all(k in data for k in ['matric_no', 'password']): return format_error_response("Missing required fields: matric_no, password") matric_no = data['matric_no'].upper().strip() password = data['password'] # Find user user = User.query.filter_by(matric_no=matric_no).first() if not user or not user.check_password(password): return format_error_response("Invalid matric number or password", 401) if not user.is_active: if user.status == 'pending': return format_error_response("Your account is pending approval by an Admin.", 403) elif user.status == 'rejected': return format_error_response("Your account registration has been rejected.", 403) else: return format_error_response("Account is inactive. Please contact admin.", 403) # Create session session['user_id'] = user.id session['matric_no'] = user.matric_no session['role'] = user.role session['name'] = user.name logger.info(f"User logged in: {matric_no} ({user.role})") return format_success_response( data={ 'user': user.to_dict(), 'redirect': '/admin' if user.role == 'admin' else ('/student' if user.role == 'student' else '/lecturer') }, message=f"Welcome back, {user.name}!" ) except Exception as e: logger.error(f"Login error: {str(e)}") return format_error_response(f"Login failed: {str(e)}", 500) @app.route('/api/auth/logout', methods=['POST']) @login_required def logout(): """Logout user and destroy session""" try: user_name = session.get('name', 'User') matric_no = session.get('matric_no', 'Unknown') # Clear all session data session.clear() logger.info(f"User logged out: {matric_no}") return format_success_response( message=f"Goodbye, {user_name}! You have been logged out successfully." ) except Exception as e: logger.error(f"Logout error: {str(e)}") return format_error_response(f"Logout failed: {str(e)}", 500) @app.route('/api/auth/current_user', methods=['GET']) def get_current_user_api(): """Get current logged in user""" user = get_current_user() if user: return format_success_response(data=user.to_dict()) return format_error_response("Not logged in", 401) # ==================== API ENDPOINTS ==================== @app.route('/api/register_face', methods=['POST']) def register_face(): """ Register student face encoding Implements: PB-1 (Register Student Face Data) """ try: data = request.get_json() # Validate input if not all(k in data for k in ['student_id', 'name', 'image']): return format_error_response("Missing required fields: student_id, name, image") student_id = data['student_id'] name = data['name'] email = data.get('email') image_base64 = data['image'] # Check if student already exists existing_student = Student.query.filter_by(student_id=student_id).first() if existing_student: return format_error_response("Student ID already registered", 409) # Get user account (if exists) user = User.query.filter_by(matric_no=student_id).first() # Convert base64 to image try: image = base64_to_image(image_base64) image_array = image_to_numpy(image) except Exception as e: return format_error_response(f"Invalid image data: {str(e)}") # Generate face encoding success, result, face_location = face_recognition_module.register_face(image_array) if not success: return format_error_response(result) # Create student record student = Student( student_id=student_id, name=name, email=email, user_id=user.id if user else None # Link to user account if exists ) student.set_face_encoding(result) db.session.add(student) db.session.commit() logger.info(f"Student registered: {student_id} - {name}") return format_success_response( data=student.to_dict(), message="Face registered successfully!" ) except Exception as e: db.session.rollback() logger.error(f"Registration error: {str(e)}") return format_error_response(f"Registration failed: {str(e)}", 500) @app.route('/api/verify_face', methods=['POST']) def verify_face(): """ Verify student face against stored encoding Implements: PB-3 (Face Recognition Matching) Implements KR Rule #1: ∀x ((CapturedFace(x) ∧ MatchStored(x)) → FaceMatch(x)) """ try: data = request.get_json() if not all(k in data for k in ['student_id', 'image']): return format_error_response("Missing required fields: student_id, image") student_id = data['student_id'] image_base64 = data['image'] # Get student record student = Student.query.filter_by(student_id=student_id).first() if not student: return format_error_response("Student not found. Please register first.", 404) if not student.is_active: return format_error_response("Student account is inactive", 403) # Get stored encoding stored_encoding = student.get_face_encoding() if not stored_encoding: return format_error_response("No face encoding found. Please register your face.", 404) # Convert base64 to image try: image = base64_to_image(image_base64) image_array = image_to_numpy(image) except Exception as e: return format_error_response(f"Invalid image data: {str(e)}") # Generate encoding from captured image success, result = face_recognition_module.generate_face_encoding(image_array) if not success: return format_error_response(result) captured_encoding = result # Verify face match is_match, confidence = face_recognition_module.verify_face(captured_encoding, stored_encoding) if is_match: logger.info(f"Face verified for student {student_id}: confidence={confidence:.2f}%") return format_success_response( data={ 'verified': True, 'confidence': round(confidence, 2), 'student': student.to_dict() }, message=f"Face verified! Confidence: {confidence:.1f}%" ) else: logger.warning(f"Face verification failed for {student_id}: confidence={confidence:.2f}%") return format_error_response( f"Face verification failed. Confidence too low: {confidence:.1f}%", 401 ) except Exception as e: logger.error(f"Face verification error: {str(e)}") return format_error_response(f"Verification failed: {str(e)}", 500) @app.route('/api/verify_location', methods=['POST']) def verify_location(): """ Verify student location against classroom boundaries Implements: PB-5 (Geofencing Logic – Classroom Detection) Implements KR Rule #2: ∀x[(Student(x) ∧ IsWithinAllowedArea(x)) → VerifiedLocation(x)] """ try: data = request.get_json() if not all(k in data for k in ['latitude', 'longitude', 'session_id']): return format_error_response("Missing required fields: latitude, longitude, session_id") latitude = data['latitude'] longitude = data['longitude'] session_id = data['session_id'] # Get session session_obj = AttendanceSession.query.get(session_id) if not session_obj: return format_error_response("Session not found", 404) if not session_obj.is_active: return format_error_response("Session is not active", 403) # Verify location result = geolocation_module.verify_location( latitude, longitude, session_obj.classroom_id ) if result['verified']: logger.info(f"Location verified: distance={result['distance']:.2f}m") return format_success_response( data={ 'verified': True, 'distance': round(result['distance'], 2), 'classroom': session_obj.classroom.to_dict() }, message=result['message'] ) else: logger.warning(f"Location verification failed: {result['error']}") return format_error_response(result['error'] or result['message'], 403) except Exception as e: logger.error(f"Location verification error: {str(e)}") return format_error_response(f"Location verification failed: {str(e)}", 500) @app.route('/api/mark_attendance', methods=['POST']) def mark_attendance(): """ Mark student attendance after all verifications Implements: PB-12 (Mark Attendance in Database) Implements KR Rule #4: ∀x ((FaceMatch(x) ∧ LocationValid(x)) → GrantCodeAccess(x)) Implements KR Rule #5: ∀x ((ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)) → MarkPresent(System,x)) """ try: data = request.get_json() required_fields = ['student_id', 'session_id', 'code', 'latitude', 'longitude'] if not all(k in data for k in required_fields): return format_error_response(f"Missing required fields: {', '.join(required_fields)}") student_id = data['student_id'] session_id = data['session_id'] code = data['code'] latitude = data['latitude'] longitude = data['longitude'] # Get student student = Student.query.filter_by(student_id=student_id).first() if not student: return format_error_response("Student not found", 404) # Get session session_obj = AttendanceSession.query.get(session_id) if not session_obj: return format_error_response("Session not found", 404) if not session_obj.is_active: return format_error_response("Session is not active", 403) # Check if already marked existing_record = AttendanceRecord.query.filter_by( session_id=session_id, student_id=student.id ).first() if existing_record: return format_error_response("Attendance already marked for this session", 409) # Validate code is_valid, message = attendance_code_module.is_code_valid(session_id, code) if not is_valid: return format_error_response(message, 401) # Verify location one more time location_result = geolocation_module.verify_location( latitude, longitude, session_obj.classroom_id ) if not location_result['verified']: return format_error_response(location_result['error'] or "Location verification failed", 403) # Create attendance record attendance = AttendanceRecord( session_id=session_id, student_id=student.id, face_verified=True, # Assumed verified before reaching this endpoint location_verified=True, code_verified=True, student_latitude=float(latitude), student_longitude=float(longitude), distance_from_classroom=location_result['distance'], status='present' ) db.session.add(attendance) db.session.commit() logger.info(f"Attendance marked: {student.name} for session {session_id}") return format_success_response( data=attendance.to_dict(), message="Attendance marked successfully! ✓" ) except Exception as e: db.session.rollback() logger.error(f"Mark attendance error: {str(e)}") return format_error_response(f"Failed to mark attendance: {str(e)}", 500) @app.route('/api/create_session', methods=['POST']) def create_session(): """ Create new attendance session Implements: US-06 (Generate Auto-Refresh Codes) """ try: data = request.get_json() if not all(k in data for k in ['course_name', 'classroom_id', 'lecturer_name']): return format_error_response("Missing required fields: course_name, classroom_id, lecturer_name") course_name = data['course_name'] classroom_id = data['classroom_id'] lecturer_name = data['lecturer_name'] # Verify classroom exists classroom = Classroom.query.get(classroom_id) if not classroom: return format_error_response("Classroom not found", 404) # Create session session_obj = AttendanceSession( course_name=course_name, classroom_id=classroom_id, lecturer_name=lecturer_name, is_active=True ) db.session.add(session_obj) db.session.commit() # Generate initial code success, code = attendance_code_module.create_session_code(session_obj.id) if not success: return format_error_response(f"Session created but code generation failed: {code}") logger.info(f"Session created: {course_name} by {lecturer_name}") return format_success_response( data=session_obj.to_dict(), message="Session created successfully!" ) except Exception as e: db.session.rollback() logger.error(f"Create session error: {str(e)}") return format_error_response(f"Failed to create session: {str(e)}", 500) @app.route('/api/get_session/', methods=['GET']) def get_session(session_id): """Get session details including current code""" try: session_obj = AttendanceSession.query.get(session_id) if not session_obj: return format_error_response("Session not found", 404) # Get code status code_status = attendance_code_module.get_code_status(session_id) # Auto-refresh if needed if code_status.get('needs_refresh'): attendance_code_module.auto_refresh_code(session_id) code_status = attendance_code_module.get_code_status(session_id) session_data = session_obj.to_dict() session_data['code_status'] = code_status return format_success_response(data=session_data) except Exception as e: logger.error(f"Get session error: {str(e)}") return format_error_response(f"Failed to get session: {str(e)}", 500) @app.route('/api/get_active_sessions', methods=['GET']) def get_active_sessions(): """Get all active sessions""" try: sessions = AttendanceSession.query.filter_by(is_active=True).all() return format_success_response( data=[session.to_dict() for session in sessions] ) except Exception as e: logger.error(f"Get active sessions error: {str(e)}") return format_error_response(f"Failed to get sessions: {str(e)}", 500) @app.route('/api/get_attendance/', methods=['GET']) def get_attendance(session_id): """ Get attendance records for a session Implements: US-07 (View Real-time Attendance) """ try: session_obj = AttendanceSession.query.get(session_id) if not session_obj: return format_error_response("Session not found", 404) records = AttendanceRecord.query.filter_by(session_id=session_id).all() return format_success_response( data={ 'session': session_obj.to_dict(), 'records': [record.to_dict() for record in records], 'total_present': len(records) } ) except Exception as e: logger.error(f"Get attendance error: {str(e)}") return format_error_response(f"Failed to get attendance: {str(e)}", 500) @app.route('/api/end_session/', methods=['POST']) def end_session(session_id): """End an attendance session""" try: session_obj = AttendanceSession.query.get(session_id) if not session_obj: return format_error_response("Session not found", 404) session_obj.is_active = False session_obj.end_time = datetime.utcnow() db.session.commit() logger.info(f"Session ended: {session_id}") return format_success_response(message="Session ended successfully") except Exception as e: db.session.rollback() logger.error(f"End session error: {str(e)}") return format_error_response(f"Failed to end session: {str(e)}", 500) @app.route('/api/student/history', methods=['GET']) @login_required @role_required('student') def get_student_history(): """Get attendance history for logged-in student""" try: # Get current user's student profile current_user = get_current_user() student = Student.query.filter_by(student_id=current_user.matric_no).first() if not student: return format_error_response("Student profile not found", 404) # Get all attendance records for this student records = AttendanceRecord.query.filter_by(student_id=student.id)\ .order_by(AttendanceRecord.marked_at.desc()).all() # Format response with session details history = [] for record in records: session = record.session history.append({ 'id': record.id, 'course_name': session.course_name, 'lecturer_name': session.lecturer_name, 'classroom': session.classroom.name, 'building': session.classroom.building, 'status': record.status, 'marked_at': record.marked_at.isoformat() if record.marked_at else None, 'session_date': session.start_time.isoformat() if session.start_time else None, 'face_verified': record.face_verified, 'location_verified': record.location_verified, 'code_verified': record.code_verified, 'distance_from_classroom': round(record.distance_from_classroom, 2) if record.distance_from_classroom else None }) return format_success_response( data={ 'student': student.to_dict(), 'total_records': len(history), 'history': history } ) except Exception as e: logger.error(f"Get student history error: {str(e)}") return format_error_response(f"Failed to get history: {str(e)}", 500) @app.route('/api/lecturer/history', methods=['GET']) @login_required @role_required('lecturer') def get_lecturer_history(): """Get session history for logged-in lecturer""" try: current_user = get_current_user() # Get all sessions created by this lecturer (case-insensitive match by name) from sqlalchemy import func sessions = AttendanceSession.query.filter( func.lower(AttendanceSession.lecturer_name) == func.lower(current_user.name) ).order_by(AttendanceSession.start_time.desc()).all() logger.info(f"Fetched {len(sessions)} sessions for lecturer: {current_user.name}") # Format response with attendance statistics history = [] for session in sessions: # Count attendance records total_attendance = len(session.attendance_records) present_count = sum(1 for r in session.attendance_records if r.status == 'present') # Calculate session duration if session.end_time and session.start_time: duration_minutes = int((session.end_time - session.start_time).total_seconds() / 60) elif session.is_active and session.start_time: duration_minutes = int((datetime.utcnow() - session.start_time).total_seconds() / 60) else: duration_minutes = 0 history.append({ 'id': session.id, 'course_name': session.course_name, 'lecturer_name': session.lecturer_name, 'classroom': session.classroom.name, 'building': session.classroom.building, 'start_time': session.start_time.isoformat() if session.start_time else None, 'end_time': session.end_time.isoformat() if session.end_time else None, 'is_active': session.is_active, 'duration_minutes': duration_minutes, 'total_attendance': total_attendance, 'present_count': present_count, 'attendance_records': [ { 'student_id': r.student.student_id if r.student else 'Unknown', 'student_name': r.student.name if r.student else 'Unknown', 'status': r.status, 'marked_at': r.marked_at.isoformat() if r.marked_at else None, 'face_verified': r.face_verified, 'location_verified': r.location_verified, 'code_verified': r.code_verified } for r in session.attendance_records ] }) return format_success_response( data={ 'lecturer': { 'name': current_user.name, 'matric_no': current_user.matric_no }, 'total_sessions': len(history), 'history': history } ) except Exception as e: logger.error(f"Get lecturer history error: {str(e)}") return format_error_response(f"Failed to get history: {str(e)}", 500) @app.route('/api/classrooms', methods=['GET']) def get_classrooms(): """Get all classrooms""" try: classrooms = geolocation_module.get_all_classrooms() return format_success_response(data=classrooms) except Exception as e: logger.error(f"Get classrooms error: {str(e)}") return format_error_response(f"Failed to get classrooms: {str(e)}", 500) @app.route('/api/admin/users', methods=['GET']) @login_required @role_required('admin') def admin_get_users(): """Get list of users for admin management""" try: status = request.args.get('status', 'pending') query = User.query if status != 'all': query = query.filter_by(status=status) users = query.order_by(User.created_at.desc()).all() return format_success_response(data=[u.to_dict() for u in users]) except Exception as e: logger.error(f"Admin get users error: {str(e)}") return format_error_response(f"Failed to get users: {str(e)}", 500) @app.route('/api/admin/users//approve', methods=['POST']) @login_required @role_required('admin') def admin_approve_user(user_id): """Approve a user registration""" try: user = User.query.get(user_id) if not user: return format_error_response("User not found", 404) user.status = 'approved' user.is_active = True db.session.commit() logger.info(f"Admin approved user: {user.matric_no}") return format_success_response(message=f"User {user.matric_no} approved successfully.") except Exception as e: db.session.rollback() logger.error(f"Admin approve user error: {str(e)}") return format_error_response(f"Failed to approve user: {str(e)}", 500) @app.route('/api/admin/users//reject', methods=['POST']) @login_required @role_required('admin') def admin_reject_user(user_id): """Reject a user registration""" try: user = User.query.get(user_id) if not user: return format_error_response("User not found", 404) user.status = 'rejected' user.is_active = False db.session.commit() logger.info(f"Admin rejected user: {user.matric_no}") return format_success_response(message=f"User {user.matric_no} rejected.") except Exception as e: db.session.rollback() logger.error(f"Admin reject user error: {str(e)}") return format_error_response(f"Failed to reject user: {str(e)}", 500) # ==================== ERROR HANDLERS ==================== @app.errorhandler(404) def not_found(error): return format_error_response("Resource not found", 404) @app.errorhandler(500) def internal_error(error): db.session.rollback() return format_error_response("Internal server error", 500) # ==================== MAIN ==================== if __name__ == '__main__': with app.app_context(): db.create_all() logger.info("Database tables created") app.run(debug=True, host='0.0.0.0', port=5000)