File size: 34,790 Bytes
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a17009
 
 
 
 
a36803c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d2e44b
 
 
a36803c
49f905d
 
a36803c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f905d
3d2e44b
4669b4f
 
 
 
 
 
 
 
 
 
 
3d2e44b
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f905d
 
 
 
 
 
 
 
304698d
 
 
d251a62
 
 
49f905d
 
d251a62
 
 
 
 
304698d
 
 
 
 
 
d251a62
 
 
49f905d
 
d251a62
 
 
 
 
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4669b4f
304698d
 
 
 
4669b4f
 
 
304698d
 
 
 
 
 
 
 
 
 
49f905d
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f905d
 
 
 
 
 
304698d
 
 
 
 
 
 
 
 
 
 
 
49f905d
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f905d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304698d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
"""
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/<int:session_id>', 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/<int:session_id>', 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/<int:session_id>', 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/<int:user_id>/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/<int:user_id>/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)