Upload 30 files
Browse files- .gitignore +65 -0
- README.md +338 -10
- _navbar.html +49 -0
- add_home_venue.py +67 -0
- add_utm_venues.py +132 -0
- app.py +791 -0
- attendance_code_module.py +232 -0
- attendr.db +0 -0
- attendr.sql +134 -0
- auth.py +41 -0
- clear_database.py +39 -0
- config.py +39 -0
- dump_db_to_sql.py +37 -0
- face_recognition_module.py +160 -0
- face_recognition_module_PRODUCTION.py +193 -0
- fix_sql_quotes.py +23 -0
- geolocation_module.py +215 -0
- index.html +172 -0
- init_db.py +89 -0
- lecturer.html +298 -0
- lecturer_history.html +248 -0
- login.html +141 -0
- models.py +195 -0
- register.html +366 -0
- requirements.txt +8 -0
- student.html +149 -0
- student_history.html +177 -0
- style.css +593 -0
- update_utm_coordinates.py +65 -0
- utils.py +84 -0
.gitignore
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .gitignore for Attendr Smart Attendance System
|
| 2 |
+
|
| 3 |
+
# Python
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*$py.class
|
| 7 |
+
*.so
|
| 8 |
+
.Python
|
| 9 |
+
build/
|
| 10 |
+
develop-eggs/
|
| 11 |
+
dist/
|
| 12 |
+
downloads/
|
| 13 |
+
eggs/
|
| 14 |
+
.eggs/
|
| 15 |
+
lib/
|
| 16 |
+
lib64/
|
| 17 |
+
parts/
|
| 18 |
+
sdist/
|
| 19 |
+
var/
|
| 20 |
+
wheels/
|
| 21 |
+
*.egg-info/
|
| 22 |
+
.installed.cfg
|
| 23 |
+
*.egg
|
| 24 |
+
|
| 25 |
+
# Virtual Environment
|
| 26 |
+
venv/
|
| 27 |
+
ENV/
|
| 28 |
+
env/
|
| 29 |
+
|
| 30 |
+
# Database
|
| 31 |
+
*.db
|
| 32 |
+
*.sqlite
|
| 33 |
+
*.sqlite3
|
| 34 |
+
attendr.db
|
| 35 |
+
|
| 36 |
+
# Uploads and User Data
|
| 37 |
+
uploads/
|
| 38 |
+
face_encodings/
|
| 39 |
+
|
| 40 |
+
# IDE
|
| 41 |
+
.vscode/
|
| 42 |
+
.idea/
|
| 43 |
+
*.swp
|
| 44 |
+
*.swo
|
| 45 |
+
*~
|
| 46 |
+
|
| 47 |
+
# OS
|
| 48 |
+
.DS_Store
|
| 49 |
+
Thumbs.db
|
| 50 |
+
|
| 51 |
+
# Environment variables
|
| 52 |
+
.env
|
| 53 |
+
.env.local
|
| 54 |
+
|
| 55 |
+
# Logs
|
| 56 |
+
*.log
|
| 57 |
+
|
| 58 |
+
# Flask
|
| 59 |
+
instance/
|
| 60 |
+
.webassets-cache
|
| 61 |
+
|
| 62 |
+
# Testing
|
| 63 |
+
.pytest_cache/
|
| 64 |
+
.coverage
|
| 65 |
+
htmlcov/
|
README.md
CHANGED
|
@@ -1,10 +1,338 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Attendr - Smart Attendance System 🎓
|
| 2 |
+
|
| 3 |
+
An AI-powered smart attendance system for Universiti Teknologi Malaysia (UTM) that combines **facial recognition**, **geolocation verification**, and **auto-refresh attendance codes** to eliminate proxy attendance and streamline the attendance process.
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+
|
| 9 |
+
## ✨ Features
|
| 10 |
+
|
| 11 |
+
### For Students
|
| 12 |
+
- 🎭 **Biometric Face Recognition** - Secure identity verification using AI
|
| 13 |
+
- 📍 **GPS Location Validation** - Confirms physical presence in classroom
|
| 14 |
+
- 🔐 **Auto-Refresh Codes** - Time-limited codes prevent sharing
|
| 15 |
+
- ⚡ **Real-time Feedback** - Instant attendance confirmation
|
| 16 |
+
- 📱 **Mobile Friendly** - Works on smartphones and tablets
|
| 17 |
+
|
| 18 |
+
### For Lecturers
|
| 19 |
+
- 🎯 **One-Click Session Creation** - Quick setup for any class
|
| 20 |
+
- 🔄 **Auto-Refreshing Codes** - New code every 2 minutes
|
| 21 |
+
- 📊 **Real-time Monitoring** - Live attendance updates
|
| 22 |
+
- 📥 **Export Reports** - Download CSV for record-keeping
|
| 23 |
+
- 📈 **Attendance Analytics** - View statistics at a glance
|
| 24 |
+
|
| 25 |
+
### Security Features
|
| 26 |
+
- ✅ Prevents proxy attendance through face + location verification
|
| 27 |
+
- ✅ Time-limited codes expire automatically
|
| 28 |
+
- ✅ All verifications logged with timestamps
|
| 29 |
+
- ✅ Distance tracking from classroom center
|
| 30 |
+
|
| 31 |
+
## 🧠 AI Logic & Knowledge Representation
|
| 32 |
+
|
| 33 |
+
Attendr implements **5 Knowledge Representation (KR) rules** using First-Order Logic:
|
| 34 |
+
|
| 35 |
+
1. **Face Recognition**: `∀x ((CapturedFace(x) ∧ MatchStored(x)) → FaceMatch(x))`
|
| 36 |
+
2. **Location Verification**: `∀x[(Student(x) ∧ IsWithinAllowedArea(x)) → VerifiedLocation(x)]`
|
| 37 |
+
3. **Device Readiness**: `∀d ((CameraOn(d) ∧ GPSOn(d)) → StartVerification(d))`
|
| 38 |
+
4. **Attendance Validation**: `∀x ((FaceMatch(x) ∧ LocationValid(x)) → GrantCodeAccess(x))`
|
| 39 |
+
5. **Code Confirmation**: `∀x ((ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)) → MarkPresent(System,x))`
|
| 40 |
+
|
| 41 |
+
## 🚀 Installation
|
| 42 |
+
|
| 43 |
+
### Prerequisites
|
| 44 |
+
- Python 3.8 or higher
|
| 45 |
+
- Webcam for face capture
|
| 46 |
+
- GPS-enabled device (or browser location services)
|
| 47 |
+
|
| 48 |
+
### Step 1: Clone Repository
|
| 49 |
+
```bash
|
| 50 |
+
git clone <repository-url>
|
| 51 |
+
cd smartAttandence
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### Step 2: Install Dependencies
|
| 55 |
+
|
| 56 |
+
#### Windows (Recommended Method)
|
| 57 |
+
```powershell
|
| 58 |
+
# Install Visual C++ Build Tools first (required for dlib)
|
| 59 |
+
# Download from: https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
| 60 |
+
|
| 61 |
+
# Create virtual environment
|
| 62 |
+
python -m venv venv
|
| 63 |
+
venv\Scripts\activate
|
| 64 |
+
|
| 65 |
+
# Install dependencies
|
| 66 |
+
pip install -r requirements.txt
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
#### Alternative: Use Pre-compiled Wheels
|
| 70 |
+
If you encounter issues installing `face_recognition`, use pre-compiled wheels:
|
| 71 |
+
```powershell
|
| 72 |
+
pip install https://github.com/jloh02/dlib/releases/download/v19.22/dlib-19.22.99-cp38-cp38-win_amd64.whl
|
| 73 |
+
pip install face_recognition
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Step 3: Initialize Database
|
| 77 |
+
```bash
|
| 78 |
+
python init_db.py
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
This creates:
|
| 82 |
+
- SQLite database with all tables
|
| 83 |
+
- Sample UTM classrooms (N28, V01, C22)
|
| 84 |
+
- Test student account
|
| 85 |
+
|
| 86 |
+
### Step 4: Run Application
|
| 87 |
+
```bash
|
| 88 |
+
python app.py
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
The application will be available at: **http://localhost:5000**
|
| 92 |
+
|
| 93 |
+
## 📖 Usage Guide
|
| 94 |
+
|
| 95 |
+
### For Students
|
| 96 |
+
|
| 97 |
+
1. **Register Your Face**
|
| 98 |
+
- Visit `/register`
|
| 99 |
+
- Enter Student ID and Name
|
| 100 |
+
- Capture your face using webcam
|
| 101 |
+
- System stores your biometric encoding
|
| 102 |
+
|
| 103 |
+
2. **Mark Attendance**
|
| 104 |
+
- Visit `/student`
|
| 105 |
+
- Enter Student ID and select active session
|
| 106 |
+
- Enable camera and GPS permissions
|
| 107 |
+
- System verifies your face and location
|
| 108 |
+
- Enter attendance code displayed by lecturer
|
| 109 |
+
- Attendance marked instantly!
|
| 110 |
+
|
| 111 |
+
### For Lecturers
|
| 112 |
+
|
| 113 |
+
1. **Create Session**
|
| 114 |
+
- Visit `/lecturer`
|
| 115 |
+
- Enter course name and your name
|
| 116 |
+
- Select classroom location
|
| 117 |
+
- Click "Create Session"
|
| 118 |
+
|
| 119 |
+
2. **Display Code**
|
| 120 |
+
- Large attendance code appears on screen
|
| 121 |
+
- Code auto-refreshes every 2 minutes
|
| 122 |
+
- Show this code to students in class
|
| 123 |
+
|
| 124 |
+
3. **Monitor Attendance**
|
| 125 |
+
- View real-time attendance list
|
| 126 |
+
- See verification details (distance, time)
|
| 127 |
+
- Export CSV report when done
|
| 128 |
+
|
| 129 |
+
## 🗂️ Project Structure
|
| 130 |
+
|
| 131 |
+
```
|
| 132 |
+
smartAttandence/
|
| 133 |
+
├── app.py # Main Flask application
|
| 134 |
+
├── config.py # Configuration settings
|
| 135 |
+
├── models.py # Database models
|
| 136 |
+
├── init_db.py # Database initialization
|
| 137 |
+
├── requirements.txt # Python dependencies
|
| 138 |
+
│
|
| 139 |
+
├── face_recognition_module.py # Face recognition logic (KR Rule #1)
|
| 140 |
+
├── geolocation_module.py # GPS verification (KR Rule #2)
|
| 141 |
+
├── attendance_code_module.py # Auto-refresh codes (KR Rule #5)
|
| 142 |
+
├── utils.py # Helper functions
|
| 143 |
+
│
|
| 144 |
+
├── static/
|
| 145 |
+
│ ├── css/
|
| 146 |
+
│ │ └── style.css # Premium design system
|
| 147 |
+
│ └── js/
|
| 148 |
+
│ ├── student.js # Student portal logic
|
| 149 |
+
│ └── lecturer.js # Lecturer dashboard logic
|
| 150 |
+
│
|
| 151 |
+
└── templates/
|
| 152 |
+
├── index.html # Landing page
|
| 153 |
+
├── student.html # Student portal
|
| 154 |
+
├── lecturer.html # Lecturer dashboard
|
| 155 |
+
└── register.html # Face registration
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
## 🔧 Configuration
|
| 159 |
+
|
| 160 |
+
Edit `config.py` to customize:
|
| 161 |
+
|
| 162 |
+
```python
|
| 163 |
+
# Face recognition settings
|
| 164 |
+
FACE_RECOGNITION_TOLERANCE = 0.6 # Lower = more strict (0.4-0.6 recommended)
|
| 165 |
+
|
| 166 |
+
# Geolocation settings
|
| 167 |
+
GEOLOCATION_RADIUS_METERS = 50 # Classroom detection radius
|
| 168 |
+
|
| 169 |
+
# Attendance code settings
|
| 170 |
+
CODE_REFRESH_INTERVAL_SECONDS = 120 # Code refresh interval (2 minutes)
|
| 171 |
+
CODE_LENGTH = 6 # Length of attendance code
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## 🌐 API Endpoints
|
| 175 |
+
|
| 176 |
+
### Student Endpoints
|
| 177 |
+
- `POST /api/register_face` - Register student face encoding
|
| 178 |
+
- `POST /api/verify_face` - Verify face against stored encoding
|
| 179 |
+
- `POST /api/verify_location` - Validate GPS location
|
| 180 |
+
- `POST /api/mark_attendance` - Mark attendance with code
|
| 181 |
+
|
| 182 |
+
### Lecturer Endpoints
|
| 183 |
+
- `POST /api/create_session` - Create attendance session
|
| 184 |
+
- `GET /api/get_session/<id>` - Get session details with current code
|
| 185 |
+
- `GET /api/get_attendance/<id>` - Get attendance records
|
| 186 |
+
- `POST /api/end_session/<id>` - End session
|
| 187 |
+
|
| 188 |
+
### General Endpoints
|
| 189 |
+
- `GET /api/get_active_sessions` - List all active sessions
|
| 190 |
+
- `GET /api/classrooms` - Get all classrooms
|
| 191 |
+
|
| 192 |
+
## 🧪 Testing
|
| 193 |
+
|
| 194 |
+
### Manual Testing Checklist
|
| 195 |
+
|
| 196 |
+
**Face Recognition:**
|
| 197 |
+
- [ ] Register new student face
|
| 198 |
+
- [ ] Verify with same person (should succeed)
|
| 199 |
+
- [ ] Verify with different person (should fail)
|
| 200 |
+
- [ ] Test with poor lighting
|
| 201 |
+
- [ ] Test with glasses/mask
|
| 202 |
+
|
| 203 |
+
**Geolocation:**
|
| 204 |
+
- [ ] Mark attendance from inside classroom (should succeed)
|
| 205 |
+
- [ ] Mark attendance from outside radius (should fail)
|
| 206 |
+
- [ ] Verify distance calculation accuracy
|
| 207 |
+
|
| 208 |
+
**Auto-Refresh Codes:**
|
| 209 |
+
- [ ] Code refreshes every 2 minutes
|
| 210 |
+
- [ ] Expired code rejected
|
| 211 |
+
- [ ] Valid code accepted
|
| 212 |
+
- [ ] Code sharing prevented
|
| 213 |
+
|
| 214 |
+
**End-to-End Flow:**
|
| 215 |
+
- [ ] Complete student registration
|
| 216 |
+
- [ ] Lecturer creates session
|
| 217 |
+
- [ ] Student marks attendance successfully
|
| 218 |
+
- [ ] Attendance appears in real-time
|
| 219 |
+
- [ ] Export CSV works
|
| 220 |
+
|
| 221 |
+
## 🐛 Troubleshooting
|
| 222 |
+
|
| 223 |
+
### Face Recognition Issues
|
| 224 |
+
|
| 225 |
+
**Problem:** `dlib` installation fails on Windows
|
| 226 |
+
**Solution:**
|
| 227 |
+
1. Install Visual C++ Build Tools
|
| 228 |
+
2. Or use pre-compiled wheel: `pip install dlib-19.22.99-cp38-cp38-win_amd64.whl`
|
| 229 |
+
|
| 230 |
+
**Problem:** "No face detected"
|
| 231 |
+
**Solution:**
|
| 232 |
+
- Ensure good lighting
|
| 233 |
+
- Face camera directly
|
| 234 |
+
- Remove glasses/mask if possible
|
| 235 |
+
- Move closer to camera
|
| 236 |
+
|
| 237 |
+
### GPS Issues
|
| 238 |
+
|
| 239 |
+
**Problem:** Location permission denied
|
| 240 |
+
**Solution:**
|
| 241 |
+
- Enable location services in browser settings
|
| 242 |
+
- Use HTTPS (required for geolocation API)
|
| 243 |
+
- Check device GPS is enabled
|
| 244 |
+
|
| 245 |
+
**Problem:** "Location unavailable"
|
| 246 |
+
**Solution:**
|
| 247 |
+
- Ensure GPS is enabled on device
|
| 248 |
+
- Try outdoors for better signal
|
| 249 |
+
- Check browser location permissions
|
| 250 |
+
|
| 251 |
+
### Code Validation Issues
|
| 252 |
+
|
| 253 |
+
**Problem:** "Code has expired"
|
| 254 |
+
**Solution:**
|
| 255 |
+
- Enter code within 2-minute window
|
| 256 |
+
- Check lecturer's displayed code
|
| 257 |
+
- Ensure system clocks are synchronized
|
| 258 |
+
|
| 259 |
+
## 📊 Database Schema
|
| 260 |
+
|
| 261 |
+
### Students Table
|
| 262 |
+
- `id` - Primary key
|
| 263 |
+
- `student_id` - Unique student identifier
|
| 264 |
+
- `name` - Student name
|
| 265 |
+
- `email` - Email address
|
| 266 |
+
- `face_encoding` - Biometric data (JSON)
|
| 267 |
+
- `registered_at` - Registration timestamp
|
| 268 |
+
|
| 269 |
+
### Classrooms Table
|
| 270 |
+
- `id` - Primary key
|
| 271 |
+
- `name` - Classroom name (e.g., N28-01-01)
|
| 272 |
+
- `building` - Building name
|
| 273 |
+
- `latitude` - GPS latitude
|
| 274 |
+
- `longitude` - GPS longitude
|
| 275 |
+
- `radius_meters` - Geofencing radius
|
| 276 |
+
|
| 277 |
+
### AttendanceSessions Table
|
| 278 |
+
- `id` - Primary key
|
| 279 |
+
- `course_name` - Course name
|
| 280 |
+
- `lecturer_name` - Lecturer name
|
| 281 |
+
- `classroom_id` - Foreign key to Classrooms
|
| 282 |
+
- `current_code` - Active attendance code
|
| 283 |
+
- `code_generated_at` - Code generation time
|
| 284 |
+
- `is_active` - Session status
|
| 285 |
+
|
| 286 |
+
### AttendanceRecords Table
|
| 287 |
+
- `id` - Primary key
|
| 288 |
+
- `session_id` - Foreign key to AttendanceSessions
|
| 289 |
+
- `student_id` - Foreign key to Students
|
| 290 |
+
- `marked_at` - Attendance timestamp
|
| 291 |
+
- `face_verified` - Face verification status
|
| 292 |
+
- `location_verified` - Location verification status
|
| 293 |
+
- `code_verified` - Code verification status
|
| 294 |
+
- `distance_from_classroom` - Distance in meters
|
| 295 |
+
|
| 296 |
+
## 🎨 Design Philosophy
|
| 297 |
+
|
| 298 |
+
Attendr features a **premium dark mode design** with:
|
| 299 |
+
- 🌈 Vibrant gradient accents
|
| 300 |
+
- ✨ Glassmorphism effects
|
| 301 |
+
- 🎭 Smooth micro-animations
|
| 302 |
+
- 📱 Fully responsive layout
|
| 303 |
+
- 🎯 Modern typography (Inter + Outfit fonts)
|
| 304 |
+
|
| 305 |
+
## 🤝 Contributing
|
| 306 |
+
|
| 307 |
+
Contributions are welcome! Please follow these steps:
|
| 308 |
+
1. Fork the repository
|
| 309 |
+
2. Create a feature branch
|
| 310 |
+
3. Make your changes
|
| 311 |
+
4. Test thoroughly
|
| 312 |
+
5. Submit a pull request
|
| 313 |
+
|
| 314 |
+
## 📄 License
|
| 315 |
+
|
| 316 |
+
This project is licensed under the MIT License.
|
| 317 |
+
|
| 318 |
+
## 👥 Authors
|
| 319 |
+
|
| 320 |
+
Developed for Universiti Teknologi Malaysia (UTM) as part of an AI Smart Attendance System project.
|
| 321 |
+
|
| 322 |
+
## 🙏 Acknowledgments
|
| 323 |
+
|
| 324 |
+
- **OpenCV** - Computer vision library
|
| 325 |
+
- **face_recognition** - Face recognition library by Adam Geitgey
|
| 326 |
+
- **Flask** - Web framework
|
| 327 |
+
- **UTM** - Project inspiration and requirements
|
| 328 |
+
|
| 329 |
+
## 📞 Support
|
| 330 |
+
|
| 331 |
+
For issues or questions:
|
| 332 |
+
1. Check the Troubleshooting section
|
| 333 |
+
2. Review API documentation
|
| 334 |
+
3. Open an issue on GitHub
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
**Made with ❤️ for UTM Students and Lecturers**
|
_navbar.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Unified Navigation Bar Component -->
|
| 2 |
+
<!-- Copy this to all pages: index.html, login.html, register.html, student.html, student_history.html, lecturer.html -->
|
| 3 |
+
|
| 4 |
+
<nav class="navbar">
|
| 5 |
+
<div class="container navbar-content">
|
| 6 |
+
<a href="/" class="navbar-brand">✨ Attendr</a>
|
| 7 |
+
<ul class="navbar-nav">
|
| 8 |
+
<li><a href="/" class="navbar-link">Home</a></li>
|
| 9 |
+
{% if current_user %}
|
| 10 |
+
{% if current_user.role == 'student' %}
|
| 11 |
+
<li><a href="/student" class="navbar-link">Mark Attendance</a></li>
|
| 12 |
+
<li><a href="/student/history" class="navbar-link">History</a></li>
|
| 13 |
+
{% elif current_user.role == 'lecturer' %}
|
| 14 |
+
<li><a href="/lecturer" class="navbar-link">Dashboard</a></li>
|
| 15 |
+
{% endif %}
|
| 16 |
+
<li><span class="navbar-link" style="color: var(--color-primary);">👤 {{ current_user.name }}</span></li>
|
| 17 |
+
<li><a href="#" onclick="logout()" class="navbar-link">Logout</a></li>
|
| 18 |
+
{% else %}
|
| 19 |
+
<li><a href="/login" class="navbar-link">Login</a></li>
|
| 20 |
+
<li><a href="/register" class="navbar-link">Register</a></li>
|
| 21 |
+
{% endif %}
|
| 22 |
+
</ul>
|
| 23 |
+
</div>
|
| 24 |
+
</nav>
|
| 25 |
+
|
| 26 |
+
<!-- Logout Script (add to pages that need it) -->
|
| 27 |
+
<script>
|
| 28 |
+
async function logout() {
|
| 29 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const response = await fetch('/api/auth/logout', {
|
| 33 |
+
method: 'POST',
|
| 34 |
+
headers: { 'Content-Type': 'application/json' }
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const data = await response.json();
|
| 38 |
+
|
| 39 |
+
if (data.success) {
|
| 40 |
+
alert(data.message);
|
| 41 |
+
window.location.href = '/';
|
| 42 |
+
} else {
|
| 43 |
+
alert('Logout failed');
|
| 44 |
+
}
|
| 45 |
+
} catch (error) {
|
| 46 |
+
alert('Logout failed');
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
</script>
|
add_home_venue.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Add Puteri Court Condominium as a test venue for home demo
|
| 3 |
+
Run this script to add your home location to the database
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app import app, db
|
| 7 |
+
from models import Classroom
|
| 8 |
+
|
| 9 |
+
def add_puteri_court():
|
| 10 |
+
"""Add Puteri Court Condominium as a test venue"""
|
| 11 |
+
|
| 12 |
+
with app.app_context():
|
| 13 |
+
print("Adding Puteri Court Condominium as test venue...")
|
| 14 |
+
|
| 15 |
+
# Check if already exists
|
| 16 |
+
existing = Classroom.query.filter_by(name='PUTERI-COURT').first()
|
| 17 |
+
|
| 18 |
+
if existing:
|
| 19 |
+
print(f"Puteri Court already exists. Updating coordinates...")
|
| 20 |
+
# Updated to correct Puteri Court location (Ampang/Pandan Mewah)
|
| 21 |
+
# Address: Jalan Pandan Mewah, Taman Pandan Mewah, 68000 Kuala Lumpur
|
| 22 |
+
existing.latitude = 3.128024
|
| 23 |
+
existing.longitude = 101.765479
|
| 24 |
+
existing.radius_meters = 1000 # 1km radius for easier testing
|
| 25 |
+
db.session.commit()
|
| 26 |
+
print(f"[OK] Updated Puteri Court")
|
| 27 |
+
else:
|
| 28 |
+
# Create new venue with correct Puteri Court location
|
| 29 |
+
puteri_court = Classroom(
|
| 30 |
+
name='PUTERI-COURT',
|
| 31 |
+
building='Puteri Court Condominium (Home Demo)',
|
| 32 |
+
latitude=3.128024, # Taman Pandan Mewah, Ampang
|
| 33 |
+
longitude=101.765479, # Taman Pandan Mewah, Ampang
|
| 34 |
+
radius_meters=1000 # 1km radius for easier testing
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
db.session.add(puteri_court)
|
| 38 |
+
db.session.commit()
|
| 39 |
+
print(f"[OK] Added Puteri Court Condominium")
|
| 40 |
+
|
| 41 |
+
# Display the venue details
|
| 42 |
+
venue = Classroom.query.filter_by(name='PUTERI-COURT').first()
|
| 43 |
+
print("\n" + "="*70)
|
| 44 |
+
print("Test Venue Added Successfully!")
|
| 45 |
+
print("="*70)
|
| 46 |
+
print(f"Venue Code: {venue.name}")
|
| 47 |
+
print(f"Building: {venue.building}")
|
| 48 |
+
print(f"Latitude: {venue.latitude}")
|
| 49 |
+
print(f"Longitude: {venue.longitude}")
|
| 50 |
+
print(f"Radius: {venue.radius_meters} meters")
|
| 51 |
+
print("="*70)
|
| 52 |
+
print("\nIMPORTANT: To get your exact GPS coordinates:")
|
| 53 |
+
print("1. Open Google Maps on your phone")
|
| 54 |
+
print("2. Long-press on your location")
|
| 55 |
+
print("3. Copy the coordinates (e.g., 1.4927, 103.7414)")
|
| 56 |
+
print("4. Update this script with your actual coordinates")
|
| 57 |
+
print("5. Run this script again to update")
|
| 58 |
+
print("\nOr you can update directly in the database:")
|
| 59 |
+
print(f"UPDATE classrooms SET latitude=YOUR_LAT, longitude=YOUR_LON WHERE name='PUTERI-COURT';")
|
| 60 |
+
print("="*70)
|
| 61 |
+
|
| 62 |
+
# Show total venues
|
| 63 |
+
total = Classroom.query.count()
|
| 64 |
+
print(f"\nTotal venues in database: {total}")
|
| 65 |
+
|
| 66 |
+
if __name__ == '__main__':
|
| 67 |
+
add_puteri_court()
|
add_utm_venues.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Add UTM Faculty of Computing Venues to Database
|
| 3 |
+
This script adds all FC classrooms and labs with their GPS coordinates
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app import app, db
|
| 7 |
+
from models import Classroom
|
| 8 |
+
|
| 9 |
+
# UTM Skudai approximate coordinates for Faculty of Computing (N28)
|
| 10 |
+
# Base coordinates: 1.5586°N, 103.6376°E
|
| 11 |
+
# Each classroom will have slightly different coordinates
|
| 12 |
+
|
| 13 |
+
def add_utm_venues():
|
| 14 |
+
"""Add all UTM FC venues to the database"""
|
| 15 |
+
|
| 16 |
+
with app.app_context():
|
| 17 |
+
print("Adding UTM Faculty of Computing venues...")
|
| 18 |
+
|
| 19 |
+
# Define all venues with their details
|
| 20 |
+
venues = [
|
| 21 |
+
# N28 Main Building - Lecture Halls (Bilik Kuliah)
|
| 22 |
+
{"code": "N28-105-01", "name": "Bilik Kuliah 1 (BK1)", "building": "N28 - Faculty of Computing", "lat": 1.5586, "lon": 103.6376, "capacity": 120},
|
| 23 |
+
{"code": "N28-116-02", "name": "Bilik Kuliah 2 (BK2)", "building": "N28 - Faculty of Computing", "lat": 1.5587, "lon": 103.6377, "capacity": 120},
|
| 24 |
+
{"code": "N28-112-02", "name": "Bilik Kuliah 3 (BK3)", "building": "N28 - Faculty of Computing", "lat": 1.5588, "lon": 103.6378, "capacity": 120},
|
| 25 |
+
{"code": "N28-111-01", "name": "Bilik Kuliah 4 (BK4)", "building": "N28 - Faculty of Computing", "lat": 1.5589, "lon": 103.6379, "capacity": 120},
|
| 26 |
+
{"code": "N28-106-01", "name": "Bilik Kuliah 5 (BK5)", "building": "N28 - Faculty of Computing", "lat": 1.5590, "lon": 103.6380, "capacity": 60},
|
| 27 |
+
{"code": "N28-107-01", "name": "Bilik Kuliah 6 (BK6)", "building": "N28 - Faculty of Computing", "lat": 1.5591, "lon": 103.6381, "capacity": 120},
|
| 28 |
+
{"code": "N28-108-01", "name": "Bilik Kuliah 7 (BK7)", "building": "N28 - Faculty of Computing", "lat": 1.5592, "lon": 103.6382, "capacity": 120},
|
| 29 |
+
|
| 30 |
+
# N28 Level 3 - Special Labs
|
| 31 |
+
{"code": "N28-351-02", "name": "Active Learning Lab (ALL)", "building": "N28 - Level 3", "lat": 1.5593, "lon": 103.6383, "capacity": 36},
|
| 32 |
+
{"code": "N28-301-01", "name": "PMO", "building": "N28 - Level 3", "lat": 1.5594, "lon": 103.6384, "capacity": 36},
|
| 33 |
+
{"code": "N28-301-01B", "name": "Bilik Mesyuarat PMO (BMPMO)", "building": "N28 - Level 3", "lat": 1.5595, "lon": 103.6385, "capacity": 30},
|
| 34 |
+
|
| 35 |
+
# N28A Building - Tutorial Rooms
|
| 36 |
+
{"code": "N28A-BT1", "name": "Bilik Tutorial 1 (BT1)", "building": "N28A - Tutorial Block", "lat": 1.5596, "lon": 103.6386, "capacity": 24},
|
| 37 |
+
{"code": "N28A-BT2", "name": "Bilik Tutorial 2 (BT2)", "building": "N28A - Tutorial Block", "lat": 1.5597, "lon": 103.6387, "capacity": 27},
|
| 38 |
+
{"code": "N28A-BT3", "name": "Bilik Tutorial 3 (BT3)", "building": "N28A - Tutorial Block", "lat": 1.5598, "lon": 103.6388, "capacity": 24},
|
| 39 |
+
{"code": "N28A-BT4", "name": "Bilik Tutorial 4 (BT4)", "building": "N28A - Tutorial Block", "lat": 1.5599, "lon": 103.6389, "capacity": 40},
|
| 40 |
+
{"code": "N28A-BK1", "name": "Bilik Kuliah 1 (BK1)", "building": "N28A - Tutorial Block", "lat": 1.5600, "lon": 103.6390, "capacity": 28},
|
| 41 |
+
{"code": "N28A-BK2", "name": "Bilik Kuliah 2 (BK2)", "building": "N28A - Tutorial Block", "lat": 1.5601, "lon": 103.6391, "capacity": 36},
|
| 42 |
+
{"code": "N28A-BK5", "name": "Bilik Kuliah 5 (BK5)", "building": "N28A - Tutorial Block", "lat": 1.5602, "lon": 103.6392, "capacity": 28},
|
| 43 |
+
{"code": "N28A-BK6", "name": "Bilik Kuliah 6 (BK6)", "building": "N28A - Tutorial Block", "lat": 1.5603, "lon": 103.6393, "capacity": 36},
|
| 44 |
+
|
| 45 |
+
# N28 Level 5 - Computer Programming Labs (Makmal Pengaturcaraan Komputer)
|
| 46 |
+
{"code": "N28-502-01", "name": "Makmal Pengaturcaraan Komputer 1 (MPK 1)", "building": "N28 - Level 5", "lat": 1.5604, "lon": 103.6394, "capacity": 60},
|
| 47 |
+
{"code": "N28-505-01", "name": "Makmal Pengaturcaraan Komputer 2 (MPK 2)", "building": "N28 - Level 5", "lat": 1.5605, "lon": 103.6395, "capacity": 60},
|
| 48 |
+
{"code": "N28-506-01", "name": "Makmal Pengaturcaraan Komputer 3 (MPK 3)", "building": "N28 - Level 5", "lat": 1.5606, "lon": 103.6396, "capacity": 60},
|
| 49 |
+
{"code": "N28-512-01", "name": "Makmal Pengaturcaraan Komputer 4 (MPK 4)", "building": "N28 - Level 5", "lat": 1.5607, "lon": 103.6397, "capacity": 60},
|
| 50 |
+
{"code": "N28-516-01", "name": "Makmal Pengaturcaraan Komputer 5 (MPK 5)", "building": "N28 - Level 5", "lat": 1.5608, "lon": 103.6398, "capacity": 25},
|
| 51 |
+
{"code": "N28-517-02", "name": "Makmal Pengaturcaraan Komputer 6 (MPK 6)", "building": "N28 - Level 5", "lat": 1.5609, "lon": 103.6399, "capacity": 60},
|
| 52 |
+
{"code": "N28-518-02", "name": "Makmal Pengaturcaraan Komputer 7 (MPK 7)", "building": "N28 - Level 5", "lat": 1.5610, "lon": 103.6400, "capacity": 60},
|
| 53 |
+
{"code": "N28-524-02", "name": "Makmal Pengaturcaraan Komputer 8 (MPK 8)", "building": "N28 - Level 5", "lat": 1.5611, "lon": 103.6401, "capacity": 60},
|
| 54 |
+
{"code": "N28-525-01", "name": "Makmal Pengaturcaraan Komputer 9 (MPK 9)", "building": "N28 - Level 5", "lat": 1.5612, "lon": 103.6402, "capacity": 60},
|
| 55 |
+
{"code": "N28-527-02", "name": "Makmal Pengaturcaraan Komputer 10 (MPK 10)", "building": "N28 - Level 5", "lat": 1.5613, "lon": 103.6403, "capacity": 60},
|
| 56 |
+
|
| 57 |
+
# N28A Level 2 - Teaching Labs
|
| 58 |
+
{"code": "N28A-02-33", "name": "Makmal Pengajaran 1 (MP1-N28A)", "building": "N28A - Level 2", "lat": 1.5614, "lon": 103.6404, "capacity": 48},
|
| 59 |
+
{"code": "N28A-02-34", "name": "Makmal Pengajaran 2 (MP2-N28A)", "building": "N28A - Level 2", "lat": 1.5615, "lon": 103.6405, "capacity": 48},
|
| 60 |
+
|
| 61 |
+
# N28 Level 2 - Specialized Labs
|
| 62 |
+
{"code": "N28-223-02", "name": "Computer Graphic & Multimedia Teaching Lab (CGMTL)", "building": "N28 - Level 2", "lat": 1.5616, "lon": 103.6406, "capacity": 40},
|
| 63 |
+
{"code": "N28-204-01", "name": "Computer Vision Lab (CVL)", "building": "N28 - Level 2", "lat": 1.5617, "lon": 103.6407, "capacity": 30},
|
| 64 |
+
{"code": "N28-203-01", "name": "Computer Vision Teaching Lab (CVTL)", "building": "N28 - Level 2", "lat": 1.5618, "lon": 103.6408, "capacity": 30},
|
| 65 |
+
{"code": "N28-202-01", "name": "Virtual Environment Teaching Lab (VETL)", "building": "N28 - Level 2", "lat": 1.5619, "lon": 103.6409, "capacity": 25},
|
| 66 |
+
|
| 67 |
+
# N28 Level 3 - Research Labs
|
| 68 |
+
{"code": "N28-330-01", "name": "Computer Security Lab (CSL)", "building": "N28 - Level 3", "lat": 1.5620, "lon": 103.6410, "capacity": 35},
|
| 69 |
+
{"code": "N28-352-01", "name": "CCNA Teaching Lab (CCNA)", "building": "N28 - Level 3", "lat": 1.5621, "lon": 103.6411, "capacity": 30},
|
| 70 |
+
{"code": "N28-321-02", "name": "Makmal CASE (MCASE)", "building": "N28 - Level 3", "lat": 1.5622, "lon": 103.6412, "capacity": 28},
|
| 71 |
+
{"code": "N28-329-01", "name": "Information System Teaching Lab (ISTL)", "building": "N28 - Level 3", "lat": 1.5623, "lon": 103.6413, "capacity": 26},
|
| 72 |
+
{"code": "N28-350-03", "name": "Data-Analytics Lab (DAL)", "building": "N28 - Level 3", "lat": 1.5624, "lon": 103.6414, "capacity": 23},
|
| 73 |
+
|
| 74 |
+
# N28 Level 4 - Advanced Labs
|
| 75 |
+
{"code": "N28-422-01", "name": "CCNP Lab (CCNP)", "building": "N28 - Level 4", "lat": 1.5625, "lon": 103.6415, "capacity": 36},
|
| 76 |
+
{"code": "N28-423-01", "name": "Makmal IDAL (IDAL)", "building": "N28 - Level 4", "lat": 1.5626, "lon": 103.6416, "capacity": 32},
|
| 77 |
+
|
| 78 |
+
# Other Campus Buildings - Large Lecture Halls
|
| 79 |
+
{"code": "L50-DK3", "name": "Dewan Kuliah 3 (L50-DK3)", "building": "Block L50 - Centre Point", "lat": 1.5580, "lon": 103.6370, "capacity": 250},
|
| 80 |
+
{"code": "P19-DK4", "name": "Dewan Kuliah 4 (P19-DK4)", "building": "Block P19 - FKE", "lat": 1.5575, "lon": 103.6365, "capacity": 250},
|
| 81 |
+
{"code": "P19-DK5", "name": "Dewan Kuliah 5 (P19-DK5)", "building": "Block P19 - FKE", "lat": 1.5574, "lon": 103.6364, "capacity": 250},
|
| 82 |
+
{"code": "P19-DK6", "name": "Dewan Kuliah 6 (P19-DK6)", "building": "Block P19 - FKE", "lat": 1.5573, "lon": 103.6363, "capacity": 250},
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
# Add all venues to database
|
| 86 |
+
added_count = 0
|
| 87 |
+
updated_count = 0
|
| 88 |
+
|
| 89 |
+
for venue in venues:
|
| 90 |
+
# Check if venue already exists
|
| 91 |
+
existing = Classroom.query.filter_by(name=venue['code']).first()
|
| 92 |
+
|
| 93 |
+
if existing:
|
| 94 |
+
# Update existing venue
|
| 95 |
+
existing.building = venue['building']
|
| 96 |
+
existing.latitude = venue['lat']
|
| 97 |
+
existing.longitude = venue['lon']
|
| 98 |
+
existing.radius_meters = 50 # Default 50m radius
|
| 99 |
+
updated_count += 1
|
| 100 |
+
print(f"Updated: {venue['code']} - {venue['name']}")
|
| 101 |
+
else:
|
| 102 |
+
# Add new venue
|
| 103 |
+
classroom = Classroom(
|
| 104 |
+
name=venue['code'],
|
| 105 |
+
building=venue['building'],
|
| 106 |
+
latitude=venue['lat'],
|
| 107 |
+
longitude=venue['lon'],
|
| 108 |
+
radius_meters=50 # Default 50m radius for all classrooms
|
| 109 |
+
)
|
| 110 |
+
db.session.add(classroom)
|
| 111 |
+
added_count += 1
|
| 112 |
+
print(f"Added: {venue['code']} - {venue['name']}")
|
| 113 |
+
|
| 114 |
+
# Commit all changes
|
| 115 |
+
db.session.commit()
|
| 116 |
+
|
| 117 |
+
print("\n" + "="*70)
|
| 118 |
+
print(f"Venue import complete!")
|
| 119 |
+
print(f"Added: {added_count} new venues")
|
| 120 |
+
print(f"Updated: {updated_count} existing venues")
|
| 121 |
+
print(f"Total venues in database: {Classroom.query.count()}")
|
| 122 |
+
print("="*70)
|
| 123 |
+
|
| 124 |
+
# Display summary by building
|
| 125 |
+
print("\nVenues by Building:")
|
| 126 |
+
buildings = db.session.query(Classroom.building).distinct().all()
|
| 127 |
+
for (building,) in buildings:
|
| 128 |
+
count = Classroom.query.filter_by(building=building).count()
|
| 129 |
+
print(f" - {building}: {count} venues")
|
| 130 |
+
|
| 131 |
+
if __name__ == '__main__':
|
| 132 |
+
add_utm_venues()
|
app.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Attendr - Smart Attendance System
|
| 3 |
+
Main Flask Application
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import Flask, render_template, request, jsonify, session, redirect, url_for, flash, send_from_directory
|
| 7 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 8 |
+
from config import Config
|
| 9 |
+
from models import db, User, Student, Classroom, AttendanceSession, AttendanceRecord
|
| 10 |
+
from face_recognition_module import face_recognition_module
|
| 11 |
+
from geolocation_module import geolocation_module
|
| 12 |
+
from attendance_code_module import attendance_code_module
|
| 13 |
+
from utils import base64_to_image, image_to_numpy, format_error_response, format_success_response
|
| 14 |
+
from auth import login_required, role_required, get_current_user
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
# Initialize Flask app
|
| 19 |
+
app = Flask(__name__, template_folder='.')
|
| 20 |
+
app.config.from_object(Config)
|
| 21 |
+
Config.init_app(app)
|
| 22 |
+
|
| 23 |
+
# Initialize database
|
| 24 |
+
db.init_app(app)
|
| 25 |
+
|
| 26 |
+
# Set up logging
|
| 27 |
+
logging.basicConfig(level=logging.INFO)
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ==================== ROUTES ====================
|
| 32 |
+
|
| 33 |
+
@app.route('/style.css')
|
| 34 |
+
def style():
|
| 35 |
+
return send_from_directory('.', 'style.css')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@app.route('/')
|
| 39 |
+
def index():
|
| 40 |
+
"""Landing page"""
|
| 41 |
+
current_user = get_current_user()
|
| 42 |
+
return render_template('index.html', current_user=current_user)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.route('/student')
|
| 46 |
+
@login_required
|
| 47 |
+
@role_required('student')
|
| 48 |
+
def student_page():
|
| 49 |
+
"""Student attendance interface - requires student login"""
|
| 50 |
+
return render_template('student.html', current_user=get_current_user())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.route('/student/history')
|
| 54 |
+
@login_required
|
| 55 |
+
@role_required('student')
|
| 56 |
+
def student_history():
|
| 57 |
+
"""Student attendance history page"""
|
| 58 |
+
return render_template('student_history.html', current_user=get_current_user())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.route('/lecturer')
|
| 62 |
+
@login_required
|
| 63 |
+
@role_required('lecturer')
|
| 64 |
+
def lecturer_page():
|
| 65 |
+
"""Lecturer dashboard - requires lecturer login"""
|
| 66 |
+
return render_template('lecturer.html', current_user=get_current_user())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.route('/lecturer/history')
|
| 70 |
+
@login_required
|
| 71 |
+
@role_required('lecturer')
|
| 72 |
+
def lecturer_history():
|
| 73 |
+
"""Lecturer session history page"""
|
| 74 |
+
return render_template('lecturer_history.html', current_user=get_current_user())
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.route('/register')
|
| 78 |
+
def register_page():
|
| 79 |
+
"""User registration page"""
|
| 80 |
+
return render_template('register.html')
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.route('/login')
|
| 84 |
+
def login_page():
|
| 85 |
+
"""Login page"""
|
| 86 |
+
return render_template('login.html')
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ==================== AUTHENTICATION API ====================
|
| 90 |
+
|
| 91 |
+
@app.route('/api/auth/register', methods=['POST'])
|
| 92 |
+
def register_user():
|
| 93 |
+
"""
|
| 94 |
+
Register new user (student or lecturer)
|
| 95 |
+
Validates matric number format based on role
|
| 96 |
+
"""
|
| 97 |
+
try:
|
| 98 |
+
data = request.get_json()
|
| 99 |
+
|
| 100 |
+
required_fields = ['matric_no', 'name', 'password', 'role']
|
| 101 |
+
if not all(k in data for k in required_fields):
|
| 102 |
+
return format_error_response(f"Missing required fields: {', '.join(required_fields)}")
|
| 103 |
+
|
| 104 |
+
matric_no = data['matric_no'].upper().strip()
|
| 105 |
+
name = data['name']
|
| 106 |
+
password = data['password']
|
| 107 |
+
role = data['role'].lower()
|
| 108 |
+
email = data.get('email')
|
| 109 |
+
|
| 110 |
+
# Validate role
|
| 111 |
+
if role not in ['student', 'lecturer']:
|
| 112 |
+
return format_error_response("Role must be 'student' or 'lecturer'")
|
| 113 |
+
|
| 114 |
+
# Validate matric number format
|
| 115 |
+
is_valid, error_msg = User.validate_matric_no(matric_no, role)
|
| 116 |
+
if not is_valid:
|
| 117 |
+
return format_error_response(error_msg, 400)
|
| 118 |
+
|
| 119 |
+
# Check if user already exists
|
| 120 |
+
existing_user = User.query.filter_by(matric_no=matric_no).first()
|
| 121 |
+
if existing_user:
|
| 122 |
+
return format_error_response("Matric number already registered", 409)
|
| 123 |
+
|
| 124 |
+
if email:
|
| 125 |
+
existing_email = User.query.filter_by(email=email).first()
|
| 126 |
+
if existing_email:
|
| 127 |
+
return format_error_response("Email already registered", 409)
|
| 128 |
+
|
| 129 |
+
# Create user
|
| 130 |
+
user = User(
|
| 131 |
+
matric_no=matric_no,
|
| 132 |
+
name=name,
|
| 133 |
+
email=email,
|
| 134 |
+
role=role
|
| 135 |
+
)
|
| 136 |
+
user.set_password(password)
|
| 137 |
+
|
| 138 |
+
db.session.add(user)
|
| 139 |
+
db.session.commit()
|
| 140 |
+
|
| 141 |
+
logger.info(f"User registered: {matric_no} ({role})")
|
| 142 |
+
|
| 143 |
+
return format_success_response(
|
| 144 |
+
data=user.to_dict(),
|
| 145 |
+
message=f"{role.capitalize()} account created successfully!"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
db.session.rollback()
|
| 150 |
+
logger.error(f"User registration error: {str(e)}")
|
| 151 |
+
return format_error_response(f"Registration failed: {str(e)}", 500)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.route('/api/auth/login', methods=['POST'])
|
| 155 |
+
def login():
|
| 156 |
+
"""Login user and create session"""
|
| 157 |
+
try:
|
| 158 |
+
data = request.get_json()
|
| 159 |
+
|
| 160 |
+
if not all(k in data for k in ['matric_no', 'password']):
|
| 161 |
+
return format_error_response("Missing required fields: matric_no, password")
|
| 162 |
+
|
| 163 |
+
matric_no = data['matric_no'].upper().strip()
|
| 164 |
+
password = data['password']
|
| 165 |
+
|
| 166 |
+
# Find user
|
| 167 |
+
user = User.query.filter_by(matric_no=matric_no).first()
|
| 168 |
+
|
| 169 |
+
if not user or not user.check_password(password):
|
| 170 |
+
return format_error_response("Invalid matric number or password", 401)
|
| 171 |
+
|
| 172 |
+
if not user.is_active:
|
| 173 |
+
return format_error_response("Account is inactive. Please contact admin.", 403)
|
| 174 |
+
|
| 175 |
+
# Create session
|
| 176 |
+
session['user_id'] = user.id
|
| 177 |
+
session['matric_no'] = user.matric_no
|
| 178 |
+
session['role'] = user.role
|
| 179 |
+
session['name'] = user.name
|
| 180 |
+
|
| 181 |
+
logger.info(f"User logged in: {matric_no} ({user.role})")
|
| 182 |
+
|
| 183 |
+
return format_success_response(
|
| 184 |
+
data={
|
| 185 |
+
'user': user.to_dict(),
|
| 186 |
+
'redirect': '/student' if user.role == 'student' else '/lecturer'
|
| 187 |
+
},
|
| 188 |
+
message=f"Welcome back, {user.name}!"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Login error: {str(e)}")
|
| 193 |
+
return format_error_response(f"Login failed: {str(e)}", 500)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@app.route('/api/auth/logout', methods=['POST'])
|
| 197 |
+
@login_required
|
| 198 |
+
def logout():
|
| 199 |
+
"""Logout user and destroy session"""
|
| 200 |
+
try:
|
| 201 |
+
user_name = session.get('name', 'User')
|
| 202 |
+
matric_no = session.get('matric_no', 'Unknown')
|
| 203 |
+
|
| 204 |
+
# Clear all session data
|
| 205 |
+
session.clear()
|
| 206 |
+
|
| 207 |
+
logger.info(f"User logged out: {matric_no}")
|
| 208 |
+
|
| 209 |
+
return format_success_response(
|
| 210 |
+
message=f"Goodbye, {user_name}! You have been logged out successfully."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"Logout error: {str(e)}")
|
| 215 |
+
return format_error_response(f"Logout failed: {str(e)}", 500)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@app.route('/api/auth/current_user', methods=['GET'])
|
| 219 |
+
def get_current_user_api():
|
| 220 |
+
"""Get current logged in user"""
|
| 221 |
+
user = get_current_user()
|
| 222 |
+
if user:
|
| 223 |
+
return format_success_response(data=user.to_dict())
|
| 224 |
+
return format_error_response("Not logged in", 401)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ==================== API ENDPOINTS ====================
|
| 228 |
+
|
| 229 |
+
@app.route('/api/register_face', methods=['POST'])
|
| 230 |
+
def register_face():
|
| 231 |
+
"""
|
| 232 |
+
Register student face encoding
|
| 233 |
+
Implements: PB-1 (Register Student Face Data)
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
data = request.get_json()
|
| 237 |
+
|
| 238 |
+
# Validate input
|
| 239 |
+
if not all(k in data for k in ['student_id', 'name', 'image']):
|
| 240 |
+
return format_error_response("Missing required fields: student_id, name, image")
|
| 241 |
+
|
| 242 |
+
student_id = data['student_id']
|
| 243 |
+
name = data['name']
|
| 244 |
+
email = data.get('email')
|
| 245 |
+
image_base64 = data['image']
|
| 246 |
+
|
| 247 |
+
# Check if student already exists
|
| 248 |
+
existing_student = Student.query.filter_by(student_id=student_id).first()
|
| 249 |
+
if existing_student:
|
| 250 |
+
return format_error_response("Student ID already registered", 409)
|
| 251 |
+
|
| 252 |
+
# Get user account (if exists)
|
| 253 |
+
user = User.query.filter_by(matric_no=student_id).first()
|
| 254 |
+
|
| 255 |
+
# Convert base64 to image
|
| 256 |
+
try:
|
| 257 |
+
image = base64_to_image(image_base64)
|
| 258 |
+
image_array = image_to_numpy(image)
|
| 259 |
+
except Exception as e:
|
| 260 |
+
return format_error_response(f"Invalid image data: {str(e)}")
|
| 261 |
+
|
| 262 |
+
# Generate face encoding
|
| 263 |
+
success, result, face_location = face_recognition_module.register_face(image_array)
|
| 264 |
+
|
| 265 |
+
if not success:
|
| 266 |
+
return format_error_response(result)
|
| 267 |
+
|
| 268 |
+
# Create student record
|
| 269 |
+
student = Student(
|
| 270 |
+
student_id=student_id,
|
| 271 |
+
name=name,
|
| 272 |
+
email=email,
|
| 273 |
+
user_id=user.id if user else None # Link to user account if exists
|
| 274 |
+
)
|
| 275 |
+
student.set_face_encoding(result)
|
| 276 |
+
|
| 277 |
+
db.session.add(student)
|
| 278 |
+
db.session.commit()
|
| 279 |
+
|
| 280 |
+
logger.info(f"Student registered: {student_id} - {name}")
|
| 281 |
+
|
| 282 |
+
return format_success_response(
|
| 283 |
+
data=student.to_dict(),
|
| 284 |
+
message="Face registered successfully!"
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
db.session.rollback()
|
| 289 |
+
logger.error(f"Registration error: {str(e)}")
|
| 290 |
+
return format_error_response(f"Registration failed: {str(e)}", 500)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@app.route('/api/verify_face', methods=['POST'])
|
| 294 |
+
def verify_face():
|
| 295 |
+
"""
|
| 296 |
+
Verify student face against stored encoding
|
| 297 |
+
Implements: PB-3 (Face Recognition Matching)
|
| 298 |
+
Implements KR Rule #1: ∀x ((CapturedFace(x) ∧ MatchStored(x)) → FaceMatch(x))
|
| 299 |
+
"""
|
| 300 |
+
try:
|
| 301 |
+
data = request.get_json()
|
| 302 |
+
|
| 303 |
+
if not all(k in data for k in ['student_id', 'image']):
|
| 304 |
+
return format_error_response("Missing required fields: student_id, image")
|
| 305 |
+
|
| 306 |
+
student_id = data['student_id']
|
| 307 |
+
image_base64 = data['image']
|
| 308 |
+
|
| 309 |
+
# Get student record
|
| 310 |
+
student = Student.query.filter_by(student_id=student_id).first()
|
| 311 |
+
if not student:
|
| 312 |
+
return format_error_response("Student not found. Please register first.", 404)
|
| 313 |
+
|
| 314 |
+
if not student.is_active:
|
| 315 |
+
return format_error_response("Student account is inactive", 403)
|
| 316 |
+
|
| 317 |
+
# Get stored encoding
|
| 318 |
+
stored_encoding = student.get_face_encoding()
|
| 319 |
+
if not stored_encoding:
|
| 320 |
+
return format_error_response("No face encoding found. Please register your face.", 404)
|
| 321 |
+
|
| 322 |
+
# Convert base64 to image
|
| 323 |
+
try:
|
| 324 |
+
image = base64_to_image(image_base64)
|
| 325 |
+
image_array = image_to_numpy(image)
|
| 326 |
+
except Exception as e:
|
| 327 |
+
return format_error_response(f"Invalid image data: {str(e)}")
|
| 328 |
+
|
| 329 |
+
# Generate encoding from captured image
|
| 330 |
+
success, result = face_recognition_module.generate_face_encoding(image_array)
|
| 331 |
+
|
| 332 |
+
if not success:
|
| 333 |
+
return format_error_response(result)
|
| 334 |
+
|
| 335 |
+
captured_encoding = result
|
| 336 |
+
|
| 337 |
+
# Verify face match
|
| 338 |
+
is_match, confidence = face_recognition_module.verify_face(captured_encoding, stored_encoding)
|
| 339 |
+
|
| 340 |
+
if is_match:
|
| 341 |
+
logger.info(f"Face verified for student {student_id}: confidence={confidence:.2f}%")
|
| 342 |
+
return format_success_response(
|
| 343 |
+
data={
|
| 344 |
+
'verified': True,
|
| 345 |
+
'confidence': round(confidence, 2),
|
| 346 |
+
'student': student.to_dict()
|
| 347 |
+
},
|
| 348 |
+
message=f"Face verified! Confidence: {confidence:.1f}%"
|
| 349 |
+
)
|
| 350 |
+
else:
|
| 351 |
+
logger.warning(f"Face verification failed for {student_id}: confidence={confidence:.2f}%")
|
| 352 |
+
return format_error_response(
|
| 353 |
+
f"Face verification failed. Confidence too low: {confidence:.1f}%",
|
| 354 |
+
401
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.error(f"Face verification error: {str(e)}")
|
| 359 |
+
return format_error_response(f"Verification failed: {str(e)}", 500)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@app.route('/api/verify_location', methods=['POST'])
|
| 363 |
+
def verify_location():
|
| 364 |
+
"""
|
| 365 |
+
Verify student location against classroom boundaries
|
| 366 |
+
Implements: PB-5 (Geofencing Logic – Classroom Detection)
|
| 367 |
+
Implements KR Rule #2: ∀x[(Student(x) ∧ IsWithinAllowedArea(x)) → VerifiedLocation(x)]
|
| 368 |
+
"""
|
| 369 |
+
try:
|
| 370 |
+
data = request.get_json()
|
| 371 |
+
|
| 372 |
+
if not all(k in data for k in ['latitude', 'longitude', 'session_id']):
|
| 373 |
+
return format_error_response("Missing required fields: latitude, longitude, session_id")
|
| 374 |
+
|
| 375 |
+
latitude = data['latitude']
|
| 376 |
+
longitude = data['longitude']
|
| 377 |
+
session_id = data['session_id']
|
| 378 |
+
|
| 379 |
+
# Get session
|
| 380 |
+
session_obj = AttendanceSession.query.get(session_id)
|
| 381 |
+
if not session_obj:
|
| 382 |
+
return format_error_response("Session not found", 404)
|
| 383 |
+
|
| 384 |
+
if not session_obj.is_active:
|
| 385 |
+
return format_error_response("Session is not active", 403)
|
| 386 |
+
|
| 387 |
+
# Verify location
|
| 388 |
+
result = geolocation_module.verify_location(
|
| 389 |
+
latitude,
|
| 390 |
+
longitude,
|
| 391 |
+
session_obj.classroom_id
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
if result['verified']:
|
| 395 |
+
logger.info(f"Location verified: distance={result['distance']:.2f}m")
|
| 396 |
+
return format_success_response(
|
| 397 |
+
data={
|
| 398 |
+
'verified': True,
|
| 399 |
+
'distance': round(result['distance'], 2),
|
| 400 |
+
'classroom': session_obj.classroom.to_dict()
|
| 401 |
+
},
|
| 402 |
+
message=result['message']
|
| 403 |
+
)
|
| 404 |
+
else:
|
| 405 |
+
logger.warning(f"Location verification failed: {result['error']}")
|
| 406 |
+
return format_error_response(result['error'] or result['message'], 403)
|
| 407 |
+
|
| 408 |
+
except Exception as e:
|
| 409 |
+
logger.error(f"Location verification error: {str(e)}")
|
| 410 |
+
return format_error_response(f"Location verification failed: {str(e)}", 500)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@app.route('/api/mark_attendance', methods=['POST'])
|
| 414 |
+
def mark_attendance():
|
| 415 |
+
"""
|
| 416 |
+
Mark student attendance after all verifications
|
| 417 |
+
Implements: PB-12 (Mark Attendance in Database)
|
| 418 |
+
Implements KR Rule #4: ∀x ((FaceMatch(x) ∧ LocationValid(x)) → GrantCodeAccess(x))
|
| 419 |
+
Implements KR Rule #5: ∀x ((ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)) → MarkPresent(System,x))
|
| 420 |
+
"""
|
| 421 |
+
try:
|
| 422 |
+
data = request.get_json()
|
| 423 |
+
|
| 424 |
+
required_fields = ['student_id', 'session_id', 'code', 'latitude', 'longitude']
|
| 425 |
+
if not all(k in data for k in required_fields):
|
| 426 |
+
return format_error_response(f"Missing required fields: {', '.join(required_fields)}")
|
| 427 |
+
|
| 428 |
+
student_id = data['student_id']
|
| 429 |
+
session_id = data['session_id']
|
| 430 |
+
code = data['code']
|
| 431 |
+
latitude = data['latitude']
|
| 432 |
+
longitude = data['longitude']
|
| 433 |
+
|
| 434 |
+
# Get student
|
| 435 |
+
student = Student.query.filter_by(student_id=student_id).first()
|
| 436 |
+
if not student:
|
| 437 |
+
return format_error_response("Student not found", 404)
|
| 438 |
+
|
| 439 |
+
# Get session
|
| 440 |
+
session_obj = AttendanceSession.query.get(session_id)
|
| 441 |
+
if not session_obj:
|
| 442 |
+
return format_error_response("Session not found", 404)
|
| 443 |
+
|
| 444 |
+
if not session_obj.is_active:
|
| 445 |
+
return format_error_response("Session is not active", 403)
|
| 446 |
+
|
| 447 |
+
# Check if already marked
|
| 448 |
+
existing_record = AttendanceRecord.query.filter_by(
|
| 449 |
+
session_id=session_id,
|
| 450 |
+
student_id=student.id
|
| 451 |
+
).first()
|
| 452 |
+
|
| 453 |
+
if existing_record:
|
| 454 |
+
return format_error_response("Attendance already marked for this session", 409)
|
| 455 |
+
|
| 456 |
+
# Validate code
|
| 457 |
+
is_valid, message = attendance_code_module.is_code_valid(session_id, code)
|
| 458 |
+
|
| 459 |
+
if not is_valid:
|
| 460 |
+
return format_error_response(message, 401)
|
| 461 |
+
|
| 462 |
+
# Verify location one more time
|
| 463 |
+
location_result = geolocation_module.verify_location(
|
| 464 |
+
latitude,
|
| 465 |
+
longitude,
|
| 466 |
+
session_obj.classroom_id
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
if not location_result['verified']:
|
| 470 |
+
return format_error_response(location_result['error'] or "Location verification failed", 403)
|
| 471 |
+
|
| 472 |
+
# Create attendance record
|
| 473 |
+
attendance = AttendanceRecord(
|
| 474 |
+
session_id=session_id,
|
| 475 |
+
student_id=student.id,
|
| 476 |
+
face_verified=True, # Assumed verified before reaching this endpoint
|
| 477 |
+
location_verified=True,
|
| 478 |
+
code_verified=True,
|
| 479 |
+
student_latitude=float(latitude),
|
| 480 |
+
student_longitude=float(longitude),
|
| 481 |
+
distance_from_classroom=location_result['distance'],
|
| 482 |
+
status='present'
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
db.session.add(attendance)
|
| 486 |
+
db.session.commit()
|
| 487 |
+
|
| 488 |
+
logger.info(f"Attendance marked: {student.name} for session {session_id}")
|
| 489 |
+
|
| 490 |
+
return format_success_response(
|
| 491 |
+
data=attendance.to_dict(),
|
| 492 |
+
message="Attendance marked successfully! ✓"
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
except Exception as e:
|
| 496 |
+
db.session.rollback()
|
| 497 |
+
logger.error(f"Mark attendance error: {str(e)}")
|
| 498 |
+
return format_error_response(f"Failed to mark attendance: {str(e)}", 500)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
@app.route('/api/create_session', methods=['POST'])
|
| 502 |
+
def create_session():
|
| 503 |
+
"""
|
| 504 |
+
Create new attendance session
|
| 505 |
+
Implements: US-06 (Generate Auto-Refresh Codes)
|
| 506 |
+
"""
|
| 507 |
+
try:
|
| 508 |
+
data = request.get_json()
|
| 509 |
+
|
| 510 |
+
if not all(k in data for k in ['course_name', 'classroom_id', 'lecturer_name']):
|
| 511 |
+
return format_error_response("Missing required fields: course_name, classroom_id, lecturer_name")
|
| 512 |
+
|
| 513 |
+
course_name = data['course_name']
|
| 514 |
+
classroom_id = data['classroom_id']
|
| 515 |
+
lecturer_name = data['lecturer_name']
|
| 516 |
+
|
| 517 |
+
# Verify classroom exists
|
| 518 |
+
classroom = Classroom.query.get(classroom_id)
|
| 519 |
+
if not classroom:
|
| 520 |
+
return format_error_response("Classroom not found", 404)
|
| 521 |
+
|
| 522 |
+
# Create session
|
| 523 |
+
session_obj = AttendanceSession(
|
| 524 |
+
course_name=course_name,
|
| 525 |
+
classroom_id=classroom_id,
|
| 526 |
+
lecturer_name=lecturer_name,
|
| 527 |
+
is_active=True
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
db.session.add(session_obj)
|
| 531 |
+
db.session.commit()
|
| 532 |
+
|
| 533 |
+
# Generate initial code
|
| 534 |
+
success, code = attendance_code_module.create_session_code(session_obj.id)
|
| 535 |
+
|
| 536 |
+
if not success:
|
| 537 |
+
return format_error_response(f"Session created but code generation failed: {code}")
|
| 538 |
+
|
| 539 |
+
logger.info(f"Session created: {course_name} by {lecturer_name}")
|
| 540 |
+
|
| 541 |
+
return format_success_response(
|
| 542 |
+
data=session_obj.to_dict(),
|
| 543 |
+
message="Session created successfully!"
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
except Exception as e:
|
| 547 |
+
db.session.rollback()
|
| 548 |
+
logger.error(f"Create session error: {str(e)}")
|
| 549 |
+
return format_error_response(f"Failed to create session: {str(e)}", 500)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
@app.route('/api/get_session/<int:session_id>', methods=['GET'])
|
| 553 |
+
def get_session(session_id):
|
| 554 |
+
"""Get session details including current code"""
|
| 555 |
+
try:
|
| 556 |
+
session_obj = AttendanceSession.query.get(session_id)
|
| 557 |
+
|
| 558 |
+
if not session_obj:
|
| 559 |
+
return format_error_response("Session not found", 404)
|
| 560 |
+
|
| 561 |
+
# Get code status
|
| 562 |
+
code_status = attendance_code_module.get_code_status(session_id)
|
| 563 |
+
|
| 564 |
+
# Auto-refresh if needed
|
| 565 |
+
if code_status.get('needs_refresh'):
|
| 566 |
+
attendance_code_module.auto_refresh_code(session_id)
|
| 567 |
+
code_status = attendance_code_module.get_code_status(session_id)
|
| 568 |
+
|
| 569 |
+
session_data = session_obj.to_dict()
|
| 570 |
+
session_data['code_status'] = code_status
|
| 571 |
+
|
| 572 |
+
return format_success_response(data=session_data)
|
| 573 |
+
|
| 574 |
+
except Exception as e:
|
| 575 |
+
logger.error(f"Get session error: {str(e)}")
|
| 576 |
+
return format_error_response(f"Failed to get session: {str(e)}", 500)
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
@app.route('/api/get_active_sessions', methods=['GET'])
|
| 580 |
+
def get_active_sessions():
|
| 581 |
+
"""Get all active sessions"""
|
| 582 |
+
try:
|
| 583 |
+
sessions = AttendanceSession.query.filter_by(is_active=True).all()
|
| 584 |
+
return format_success_response(
|
| 585 |
+
data=[session.to_dict() for session in sessions]
|
| 586 |
+
)
|
| 587 |
+
except Exception as e:
|
| 588 |
+
logger.error(f"Get active sessions error: {str(e)}")
|
| 589 |
+
return format_error_response(f"Failed to get sessions: {str(e)}", 500)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
@app.route('/api/get_attendance/<int:session_id>', methods=['GET'])
|
| 593 |
+
def get_attendance(session_id):
|
| 594 |
+
"""
|
| 595 |
+
Get attendance records for a session
|
| 596 |
+
Implements: US-07 (View Real-time Attendance)
|
| 597 |
+
"""
|
| 598 |
+
try:
|
| 599 |
+
session_obj = AttendanceSession.query.get(session_id)
|
| 600 |
+
|
| 601 |
+
if not session_obj:
|
| 602 |
+
return format_error_response("Session not found", 404)
|
| 603 |
+
|
| 604 |
+
records = AttendanceRecord.query.filter_by(session_id=session_id).all()
|
| 605 |
+
|
| 606 |
+
return format_success_response(
|
| 607 |
+
data={
|
| 608 |
+
'session': session_obj.to_dict(),
|
| 609 |
+
'records': [record.to_dict() for record in records],
|
| 610 |
+
'total_present': len(records)
|
| 611 |
+
}
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
except Exception as e:
|
| 615 |
+
logger.error(f"Get attendance error: {str(e)}")
|
| 616 |
+
return format_error_response(f"Failed to get attendance: {str(e)}", 500)
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
@app.route('/api/end_session/<int:session_id>', methods=['POST'])
|
| 620 |
+
def end_session(session_id):
|
| 621 |
+
"""End an attendance session"""
|
| 622 |
+
try:
|
| 623 |
+
session_obj = AttendanceSession.query.get(session_id)
|
| 624 |
+
|
| 625 |
+
if not session_obj:
|
| 626 |
+
return format_error_response("Session not found", 404)
|
| 627 |
+
|
| 628 |
+
session_obj.is_active = False
|
| 629 |
+
session_obj.end_time = datetime.utcnow()
|
| 630 |
+
|
| 631 |
+
db.session.commit()
|
| 632 |
+
|
| 633 |
+
logger.info(f"Session ended: {session_id}")
|
| 634 |
+
|
| 635 |
+
return format_success_response(message="Session ended successfully")
|
| 636 |
+
|
| 637 |
+
except Exception as e:
|
| 638 |
+
db.session.rollback()
|
| 639 |
+
logger.error(f"End session error: {str(e)}")
|
| 640 |
+
return format_error_response(f"Failed to end session: {str(e)}", 500)
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
@app.route('/api/student/history', methods=['GET'])
|
| 644 |
+
@login_required
|
| 645 |
+
@role_required('student')
|
| 646 |
+
def get_student_history():
|
| 647 |
+
"""Get attendance history for logged-in student"""
|
| 648 |
+
try:
|
| 649 |
+
# Get current user's student profile
|
| 650 |
+
current_user = get_current_user()
|
| 651 |
+
student = Student.query.filter_by(student_id=current_user.matric_no).first()
|
| 652 |
+
|
| 653 |
+
if not student:
|
| 654 |
+
return format_error_response("Student profile not found", 404)
|
| 655 |
+
|
| 656 |
+
# Get all attendance records for this student
|
| 657 |
+
records = AttendanceRecord.query.filter_by(student_id=student.id)\
|
| 658 |
+
.order_by(AttendanceRecord.marked_at.desc()).all()
|
| 659 |
+
|
| 660 |
+
# Format response with session details
|
| 661 |
+
history = []
|
| 662 |
+
for record in records:
|
| 663 |
+
session = record.session
|
| 664 |
+
history.append({
|
| 665 |
+
'id': record.id,
|
| 666 |
+
'course_name': session.course_name,
|
| 667 |
+
'lecturer_name': session.lecturer_name,
|
| 668 |
+
'classroom': session.classroom.name,
|
| 669 |
+
'building': session.classroom.building,
|
| 670 |
+
'status': record.status,
|
| 671 |
+
'marked_at': record.marked_at.isoformat() if record.marked_at else None,
|
| 672 |
+
'session_date': session.start_time.isoformat() if session.start_time else None,
|
| 673 |
+
'face_verified': record.face_verified,
|
| 674 |
+
'location_verified': record.location_verified,
|
| 675 |
+
'code_verified': record.code_verified,
|
| 676 |
+
'distance_from_classroom': round(record.distance_from_classroom, 2) if record.distance_from_classroom else None
|
| 677 |
+
})
|
| 678 |
+
|
| 679 |
+
return format_success_response(
|
| 680 |
+
data={
|
| 681 |
+
'student': student.to_dict(),
|
| 682 |
+
'total_records': len(history),
|
| 683 |
+
'history': history
|
| 684 |
+
}
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
except Exception as e:
|
| 688 |
+
logger.error(f"Get student history error: {str(e)}")
|
| 689 |
+
return format_error_response(f"Failed to get history: {str(e)}", 500)
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
@app.route('/api/lecturer/history', methods=['GET'])
|
| 693 |
+
@login_required
|
| 694 |
+
@role_required('lecturer')
|
| 695 |
+
def get_lecturer_history():
|
| 696 |
+
"""Get session history for logged-in lecturer"""
|
| 697 |
+
try:
|
| 698 |
+
current_user = get_current_user()
|
| 699 |
+
|
| 700 |
+
# Get all sessions created by this lecturer (match by name)
|
| 701 |
+
sessions = AttendanceSession.query.filter_by(lecturer_name=current_user.name)\
|
| 702 |
+
.order_by(AttendanceSession.start_time.desc()).all()
|
| 703 |
+
|
| 704 |
+
# Format response with attendance statistics
|
| 705 |
+
history = []
|
| 706 |
+
for session in sessions:
|
| 707 |
+
# Count attendance records
|
| 708 |
+
total_attendance = len(session.attendance_records)
|
| 709 |
+
present_count = sum(1 for r in session.attendance_records if r.status == 'present')
|
| 710 |
+
|
| 711 |
+
# Calculate session duration
|
| 712 |
+
if session.end_time and session.start_time:
|
| 713 |
+
duration_minutes = int((session.end_time - session.start_time).total_seconds() / 60)
|
| 714 |
+
elif session.is_active and session.start_time:
|
| 715 |
+
duration_minutes = int((datetime.utcnow() - session.start_time).total_seconds() / 60)
|
| 716 |
+
else:
|
| 717 |
+
duration_minutes = 0
|
| 718 |
+
|
| 719 |
+
history.append({
|
| 720 |
+
'id': session.id,
|
| 721 |
+
'course_name': session.course_name,
|
| 722 |
+
'lecturer_name': session.lecturer_name,
|
| 723 |
+
'classroom': session.classroom.name,
|
| 724 |
+
'building': session.classroom.building,
|
| 725 |
+
'start_time': session.start_time.isoformat() if session.start_time else None,
|
| 726 |
+
'end_time': session.end_time.isoformat() if session.end_time else None,
|
| 727 |
+
'is_active': session.is_active,
|
| 728 |
+
'duration_minutes': duration_minutes,
|
| 729 |
+
'total_attendance': total_attendance,
|
| 730 |
+
'present_count': present_count,
|
| 731 |
+
'attendance_records': [
|
| 732 |
+
{
|
| 733 |
+
'student_id': r.student.student_id if r.student else 'Unknown',
|
| 734 |
+
'student_name': r.student.name if r.student else 'Unknown',
|
| 735 |
+
'status': r.status,
|
| 736 |
+
'marked_at': r.marked_at.isoformat() if r.marked_at else None,
|
| 737 |
+
'face_verified': r.face_verified,
|
| 738 |
+
'location_verified': r.location_verified,
|
| 739 |
+
'code_verified': r.code_verified
|
| 740 |
+
} for r in session.attendance_records
|
| 741 |
+
]
|
| 742 |
+
})
|
| 743 |
+
|
| 744 |
+
return format_success_response(
|
| 745 |
+
data={
|
| 746 |
+
'lecturer': {
|
| 747 |
+
'name': current_user.name,
|
| 748 |
+
'matric_no': current_user.matric_no
|
| 749 |
+
},
|
| 750 |
+
'total_sessions': len(history),
|
| 751 |
+
'history': history
|
| 752 |
+
}
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
except Exception as e:
|
| 756 |
+
logger.error(f"Get lecturer history error: {str(e)}")
|
| 757 |
+
return format_error_response(f"Failed to get history: {str(e)}", 500)
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
@app.route('/api/classrooms', methods=['GET'])
|
| 761 |
+
def get_classrooms():
|
| 762 |
+
"""Get all classrooms"""
|
| 763 |
+
try:
|
| 764 |
+
classrooms = geolocation_module.get_all_classrooms()
|
| 765 |
+
return format_success_response(data=classrooms)
|
| 766 |
+
except Exception as e:
|
| 767 |
+
logger.error(f"Get classrooms error: {str(e)}")
|
| 768 |
+
return format_error_response(f"Failed to get classrooms: {str(e)}", 500)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
# ==================== ERROR HANDLERS ====================
|
| 772 |
+
|
| 773 |
+
@app.errorhandler(404)
|
| 774 |
+
def not_found(error):
|
| 775 |
+
return format_error_response("Resource not found", 404)
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
@app.errorhandler(500)
|
| 779 |
+
def internal_error(error):
|
| 780 |
+
db.session.rollback()
|
| 781 |
+
return format_error_response("Internal server error", 500)
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
# ==================== MAIN ====================
|
| 785 |
+
|
| 786 |
+
if __name__ == '__main__':
|
| 787 |
+
with app.app_context():
|
| 788 |
+
db.create_all()
|
| 789 |
+
logger.info("Database tables created")
|
| 790 |
+
|
| 791 |
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
attendance_code_module.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Attendance Code Module for Attendr
|
| 3 |
+
Implements KR Rule #5: Auto-Refresh Code Confirmation
|
| 4 |
+
∀x ((ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)) → MarkPresent(System,x))
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import random
|
| 8 |
+
import string
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from models import AttendanceSession, db
|
| 11 |
+
from config import Config
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
# Set up logging
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AttendanceCodeModule:
|
| 20 |
+
"""Handles attendance code generation, validation, and auto-refresh"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, code_length=None, refresh_interval=None):
|
| 23 |
+
"""
|
| 24 |
+
Initialize attendance code module
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
code_length: Length of the code (default from config)
|
| 28 |
+
refresh_interval: Code refresh interval in seconds (default from config)
|
| 29 |
+
"""
|
| 30 |
+
self.code_length = code_length or Config.CODE_LENGTH
|
| 31 |
+
self.refresh_interval = refresh_interval or Config.CODE_REFRESH_INTERVAL
|
| 32 |
+
logger.info(f"Attendance Code Module initialized: length={self.code_length}, refresh={self.refresh_interval}s")
|
| 33 |
+
|
| 34 |
+
def generate_code(self):
|
| 35 |
+
"""
|
| 36 |
+
Generate a random attendance code
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
String code (e.g., "A3X9K2")
|
| 40 |
+
"""
|
| 41 |
+
# Use uppercase letters and digits for clarity
|
| 42 |
+
characters = string.ascii_uppercase + string.digits
|
| 43 |
+
code = ''.join(random.choice(characters) for _ in range(self.code_length))
|
| 44 |
+
logger.info(f"Generated new code: {code}")
|
| 45 |
+
return code
|
| 46 |
+
|
| 47 |
+
def create_session_code(self, session_id):
|
| 48 |
+
"""
|
| 49 |
+
Create and store a new code for a session
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
session_id: ID of the attendance session
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
tuple: (success, code or error_message)
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
session = AttendanceSession.query.get(session_id)
|
| 59 |
+
|
| 60 |
+
if not session:
|
| 61 |
+
return False, "Session not found"
|
| 62 |
+
|
| 63 |
+
if not session.is_active:
|
| 64 |
+
return False, "Session is not active"
|
| 65 |
+
|
| 66 |
+
# Generate new code
|
| 67 |
+
new_code = self.generate_code()
|
| 68 |
+
|
| 69 |
+
# Update session
|
| 70 |
+
session.current_code = new_code
|
| 71 |
+
session.code_generated_at = datetime.utcnow()
|
| 72 |
+
|
| 73 |
+
db.session.commit()
|
| 74 |
+
|
| 75 |
+
logger.info(f"Session {session_id} code updated to: {new_code}")
|
| 76 |
+
return True, new_code
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
db.session.rollback()
|
| 80 |
+
logger.error(f"Error creating session code: {str(e)}")
|
| 81 |
+
return False, f"Failed to create code: {str(e)}"
|
| 82 |
+
|
| 83 |
+
def is_code_valid(self, session_id, entered_code):
|
| 84 |
+
"""
|
| 85 |
+
Validate if entered code matches current session code and is within refresh cycle
|
| 86 |
+
Implements: ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
session_id: ID of the attendance session
|
| 90 |
+
entered_code: Code entered by student
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
tuple: (is_valid: bool, message: str)
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
session = AttendanceSession.query.get(session_id)
|
| 97 |
+
|
| 98 |
+
if not session:
|
| 99 |
+
return False, "Session not found"
|
| 100 |
+
|
| 101 |
+
if not session.is_active:
|
| 102 |
+
return False, "Session is no longer active"
|
| 103 |
+
|
| 104 |
+
if not session.current_code:
|
| 105 |
+
return False, "No active code for this session"
|
| 106 |
+
|
| 107 |
+
# Check if code matches (case-insensitive)
|
| 108 |
+
if session.current_code.upper() != entered_code.upper():
|
| 109 |
+
logger.warning(f"Code mismatch: expected {session.current_code}, got {entered_code}")
|
| 110 |
+
return False, "Invalid code. Please check and try again."
|
| 111 |
+
|
| 112 |
+
# Check if code is within refresh cycle
|
| 113 |
+
if not session.code_generated_at:
|
| 114 |
+
return False, "Code timestamp not found"
|
| 115 |
+
|
| 116 |
+
time_elapsed = (datetime.utcnow() - session.code_generated_at).total_seconds()
|
| 117 |
+
|
| 118 |
+
if time_elapsed > self.refresh_interval:
|
| 119 |
+
logger.warning(f"Code expired: {time_elapsed:.0f}s elapsed (max: {self.refresh_interval}s)")
|
| 120 |
+
return False, f"Code has expired. Please use the current code displayed by your lecturer."
|
| 121 |
+
|
| 122 |
+
# Code is valid
|
| 123 |
+
time_remaining = self.refresh_interval - time_elapsed
|
| 124 |
+
logger.info(f"Code validated successfully. Time remaining: {time_remaining:.0f}s")
|
| 125 |
+
return True, "Code verified successfully"
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Code validation error: {str(e)}")
|
| 129 |
+
return False, f"Validation failed: {str(e)}"
|
| 130 |
+
|
| 131 |
+
def should_refresh_code(self, session_id):
|
| 132 |
+
"""
|
| 133 |
+
Check if code should be refreshed based on time elapsed
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
session_id: ID of the attendance session
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
bool: True if code should be refreshed
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
session = AttendanceSession.query.get(session_id)
|
| 143 |
+
|
| 144 |
+
if not session or not session.is_active:
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
if not session.code_generated_at:
|
| 148 |
+
return True # No code generated yet
|
| 149 |
+
|
| 150 |
+
time_elapsed = (datetime.utcnow() - session.code_generated_at).total_seconds()
|
| 151 |
+
|
| 152 |
+
return time_elapsed >= self.refresh_interval
|
| 153 |
+
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Error checking refresh status: {str(e)}")
|
| 156 |
+
return False
|
| 157 |
+
|
| 158 |
+
def auto_refresh_code(self, session_id):
|
| 159 |
+
"""
|
| 160 |
+
Automatically refresh code if needed
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
session_id: ID of the attendance session
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
tuple: (refreshed: bool, code or None)
|
| 167 |
+
"""
|
| 168 |
+
try:
|
| 169 |
+
if self.should_refresh_code(session_id):
|
| 170 |
+
success, code = self.create_session_code(session_id)
|
| 171 |
+
if success:
|
| 172 |
+
logger.info(f"Code auto-refreshed for session {session_id}")
|
| 173 |
+
return True, code
|
| 174 |
+
else:
|
| 175 |
+
return False, None
|
| 176 |
+
else:
|
| 177 |
+
# Return current code
|
| 178 |
+
session = AttendanceSession.query.get(session_id)
|
| 179 |
+
if session and session.current_code:
|
| 180 |
+
return False, session.current_code
|
| 181 |
+
return False, None
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.error(f"Auto-refresh error: {str(e)}")
|
| 185 |
+
return False, None
|
| 186 |
+
|
| 187 |
+
def get_code_status(self, session_id):
|
| 188 |
+
"""
|
| 189 |
+
Get current code status including time remaining
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
session_id: ID of the attendance session
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
dict: Code status information
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
session = AttendanceSession.query.get(session_id)
|
| 199 |
+
|
| 200 |
+
if not session:
|
| 201 |
+
return {'error': 'Session not found'}
|
| 202 |
+
|
| 203 |
+
if not session.is_active:
|
| 204 |
+
return {'error': 'Session is not active'}
|
| 205 |
+
|
| 206 |
+
if not session.current_code or not session.code_generated_at:
|
| 207 |
+
return {
|
| 208 |
+
'code': None,
|
| 209 |
+
'time_remaining': 0,
|
| 210 |
+
'needs_refresh': True
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
time_elapsed = (datetime.utcnow() - session.code_generated_at).total_seconds()
|
| 214 |
+
time_remaining = max(0, self.refresh_interval - time_elapsed)
|
| 215 |
+
needs_refresh = time_elapsed >= self.refresh_interval
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
'code': session.current_code,
|
| 219 |
+
'time_remaining': int(time_remaining),
|
| 220 |
+
'time_elapsed': int(time_elapsed),
|
| 221 |
+
'refresh_interval': self.refresh_interval,
|
| 222 |
+
'needs_refresh': needs_refresh,
|
| 223 |
+
'generated_at': session.code_generated_at.isoformat()
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"Error getting code status: {str(e)}")
|
| 228 |
+
return {'error': str(e)}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# Singleton instance
|
| 232 |
+
attendance_code_module = AttendanceCodeModule()
|
attendr.db
ADDED
|
Binary file (45.1 kB). View file
|
|
|
attendr.sql
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE DATABASE IF NOT EXISTS attendr;
|
| 2 |
+
USE attendr;
|
| 3 |
+
|
| 4 |
+
START TRANSACTION;
|
| 5 |
+
CREATE TABLE attendance_records (
|
| 6 |
+
id INTEGER NOT NULL,
|
| 7 |
+
session_id INTEGER NOT NULL,
|
| 8 |
+
student_id INTEGER NOT NULL,
|
| 9 |
+
marked_at DATETIME,
|
| 10 |
+
face_verified BOOLEAN,
|
| 11 |
+
location_verified BOOLEAN,
|
| 12 |
+
code_verified BOOLEAN,
|
| 13 |
+
student_latitude FLOAT,
|
| 14 |
+
student_longitude FLOAT,
|
| 15 |
+
distance_from_classroom FLOAT,
|
| 16 |
+
status VARCHAR(20),
|
| 17 |
+
PRIMARY KEY (id),
|
| 18 |
+
FOREIGN KEY(session_id) REFERENCES attendance_sessions (id),
|
| 19 |
+
FOREIGN KEY(student_id) REFERENCES students (id)
|
| 20 |
+
);
|
| 21 |
+
INSERT INTO `attendance_records` VALUES(1,2,1,'2025-11-22 16:39:17.926194',1,1,1,3.1222562,101.7636743,6.719226959803195314e+02,'present');
|
| 22 |
+
INSERT INTO `attendance_records` VALUES(2,3,1,'2025-11-22 16:46:07.075823',1,1,1,3.1222616,101.7636711,6.714557167968994236e+02,'present');
|
| 23 |
+
INSERT INTO `attendance_records` VALUES(3,6,1,'2025-11-22 17:12:17.627831',1,1,1,3.122261,101.7636698,6.715625427798626106e+02,'present');
|
| 24 |
+
INSERT INTO `attendance_records` VALUES(4,7,1,'2025-11-22 15:50:16.138101',1,1,1,0.0,0.0,10.0,'present');
|
| 25 |
+
INSERT INTO `attendance_records` VALUES(5,8,1,'2025-11-22 17:31:19.262173',1,1,1,3.1222586,101.7636816,6.714266242365291645e+02,'present');
|
| 26 |
+
CREATE TABLE attendance_sessions (
|
| 27 |
+
id INTEGER NOT NULL,
|
| 28 |
+
course_name VARCHAR(100) NOT NULL,
|
| 29 |
+
classroom_id INTEGER NOT NULL,
|
| 30 |
+
lecturer_name VARCHAR(100) NOT NULL,
|
| 31 |
+
start_time DATETIME,
|
| 32 |
+
end_time DATETIME,
|
| 33 |
+
is_active BOOLEAN,
|
| 34 |
+
current_code VARCHAR(10),
|
| 35 |
+
code_generated_at DATETIME,
|
| 36 |
+
PRIMARY KEY (id),
|
| 37 |
+
FOREIGN KEY(classroom_id) REFERENCES classrooms (id)
|
| 38 |
+
);
|
| 39 |
+
INSERT INTO `attendance_sessions` VALUES(1,'AI',46,'ZHANG LING HE','2025-11-22 16:34:53.967623','2025-11-22 16:35:22.483428',0,'EAB1FQ','2025-11-22 16:34:53.977748');
|
| 40 |
+
INSERT INTO `attendance_sessions` VALUES(2,'AI',46,'ZHANG LING HE','2025-11-22 16:35:34.223978','2025-11-22 16:45:32.132931',0,'87JFTB','2025-11-22 16:45:23.271856');
|
| 41 |
+
INSERT INTO `attendance_sessions` VALUES(3,'AD',46,'ZHANG LING HE','2025-11-22 16:45:46.604739','2025-11-22 16:50:30.716018',0,'RJII20','2025-11-22 16:50:22.204453');
|
| 42 |
+
INSERT INTO `attendance_sessions` VALUES(4,'SECR3104 - Applications Development',46,'ZHANG LING HE','2025-11-22 17:04:10.912458','2025-11-22 17:04:36.114555',0,'86SONH','2025-11-22 17:04:32.116897');
|
| 43 |
+
INSERT INTO `attendance_sessions` VALUES(5,'SECR3104 - Applications Development',46,'ZHANG LING HE','2025-11-22 17:04:54.989793','2025-11-22 17:09:42.210274',0,'L8D01H','2025-11-22 17:09:33.982659');
|
| 44 |
+
INSERT INTO `attendance_sessions` VALUES(6,'SECJ3553 - Artificial Intelligence',46,'ZHANG LING HE','2025-11-22 17:12:01.124969','2025-11-22 17:26:36.046635',0,'446MC7','2025-11-22 17:26:33.821599');
|
| 45 |
+
INSERT INTO `attendance_sessions` VALUES(7,'TEST101 - Testing',1,'Test Lecturer','2025-11-22 15:20:16.131518','2025-11-22 16:20:16.131518',0,NULL,NULL);
|
| 46 |
+
INSERT INTO `attendance_sessions` VALUES(8,'SECR1013 - Digital Logic',46,'ZHANG LING HE','2025-11-22 17:30:15.307311','2025-11-22 17:31:23.154043',0,'D5DYN5','2025-11-22 17:31:15.654521');
|
| 47 |
+
CREATE TABLE classrooms (
|
| 48 |
+
id INTEGER NOT NULL,
|
| 49 |
+
name VARCHAR(100) NOT NULL,
|
| 50 |
+
building VARCHAR(100),
|
| 51 |
+
latitude FLOAT NOT NULL,
|
| 52 |
+
longitude FLOAT NOT NULL,
|
| 53 |
+
radius_meters INTEGER,
|
| 54 |
+
PRIMARY KEY (id)
|
| 55 |
+
);
|
| 56 |
+
INSERT INTO `classrooms` VALUES(1,'N28-105-01','N28 - Faculty of Computing',1.5638,103.6388,50);
|
| 57 |
+
INSERT INTO `classrooms` VALUES(2,'N28-116-02','N28 - Faculty of Computing',1.5639,103.6388,50);
|
| 58 |
+
INSERT INTO `classrooms` VALUES(3,'N28-112-02','N28 - Faculty of Computing',1.564,103.6388,50);
|
| 59 |
+
INSERT INTO `classrooms` VALUES(4,'N28-111-01','N28 - Faculty of Computing',1.5641,103.6388,50);
|
| 60 |
+
INSERT INTO `classrooms` VALUES(5,'N28-106-01','N28 - Faculty of Computing',1.5642,103.6388,50);
|
| 61 |
+
INSERT INTO `classrooms` VALUES(6,'N28-107-01','N28 - Faculty of Computing',1.5643,103.6388,50);
|
| 62 |
+
INSERT INTO `classrooms` VALUES(7,'N28-108-01','N28 - Faculty of Computing',1.5644,103.6388,50);
|
| 63 |
+
INSERT INTO `classrooms` VALUES(8,'N28-351-02','N28 - Level 3',1.5645,103.6388,50);
|
| 64 |
+
INSERT INTO `classrooms` VALUES(9,'N28-301-01','N28 - Level 3',1.5646,103.6388,50);
|
| 65 |
+
INSERT INTO `classrooms` VALUES(10,'N28-301-01B','N28 - Level 3',1.5647,103.6388,50);
|
| 66 |
+
INSERT INTO `classrooms` VALUES(11,'N28A-BT1','N28A - Tutorial Block',1.5638,103.6389,50);
|
| 67 |
+
INSERT INTO `classrooms` VALUES(12,'N28A-BT2','N28A - Tutorial Block',1.5639,103.6389,50);
|
| 68 |
+
INSERT INTO `classrooms` VALUES(13,'N28A-BT3','N28A - Tutorial Block',1.564,103.6389,50);
|
| 69 |
+
INSERT INTO `classrooms` VALUES(14,'N28A-BT4','N28A - Tutorial Block',1.5641,103.6389,50);
|
| 70 |
+
INSERT INTO `classrooms` VALUES(15,'N28A-BK1','N28A - Tutorial Block',1.5642,103.6389,50);
|
| 71 |
+
INSERT INTO `classrooms` VALUES(16,'N28A-BK2','N28A - Tutorial Block',1.5643,103.6389,50);
|
| 72 |
+
INSERT INTO `classrooms` VALUES(17,'N28A-BK5','N28A - Tutorial Block',1.5644,103.6389,50);
|
| 73 |
+
INSERT INTO `classrooms` VALUES(18,'N28A-BK6','N28A - Tutorial Block',1.5645,103.6389,50);
|
| 74 |
+
INSERT INTO `classrooms` VALUES(19,'N28-502-01','N28 - Level 5',1.5646,103.6389,50);
|
| 75 |
+
INSERT INTO `classrooms` VALUES(20,'N28-505-01','N28 - Level 5',1.5647,103.6389,50);
|
| 76 |
+
INSERT INTO `classrooms` VALUES(21,'N28-506-01','N28 - Level 5',1.5638,1.036390000000000101e+02,50);
|
| 77 |
+
INSERT INTO `classrooms` VALUES(22,'N28-512-01','N28 - Level 5',1.5639,1.036390000000000101e+02,50);
|
| 78 |
+
INSERT INTO `classrooms` VALUES(23,'N28-516-01','N28 - Level 5',1.564,1.036390000000000101e+02,50);
|
| 79 |
+
INSERT INTO `classrooms` VALUES(24,'N28-517-02','N28 - Level 5',1.5641,1.036390000000000101e+02,50);
|
| 80 |
+
INSERT INTO `classrooms` VALUES(25,'N28-518-02','N28 - Level 5',1.5642,1.036390000000000101e+02,50);
|
| 81 |
+
INSERT INTO `classrooms` VALUES(26,'N28-524-02','N28 - Level 5',1.5643,1.036390000000000101e+02,50);
|
| 82 |
+
INSERT INTO `classrooms` VALUES(27,'N28-525-01','N28 - Level 5',1.5644,1.036390000000000101e+02,50);
|
| 83 |
+
INSERT INTO `classrooms` VALUES(28,'N28-527-02','N28 - Level 5',1.5645,1.036390000000000101e+02,50);
|
| 84 |
+
INSERT INTO `classrooms` VALUES(29,'N28A-02-33','N28A - Level 2',1.5646,1.036390000000000101e+02,50);
|
| 85 |
+
INSERT INTO `classrooms` VALUES(30,'N28A-02-34','N28A - Level 2',1.5647,1.036390000000000101e+02,50);
|
| 86 |
+
INSERT INTO `classrooms` VALUES(31,'N28-223-02','N28 - Level 2',1.5638,103.6391,50);
|
| 87 |
+
INSERT INTO `classrooms` VALUES(32,'N28-204-01','N28 - Level 2',1.5639,103.6391,50);
|
| 88 |
+
INSERT INTO `classrooms` VALUES(33,'N28-203-01','N28 - Level 2',1.564,103.6391,50);
|
| 89 |
+
INSERT INTO `classrooms` VALUES(34,'N28-202-01','N28 - Level 2',1.5641,103.6391,50);
|
| 90 |
+
INSERT INTO `classrooms` VALUES(35,'N28-330-01','N28 - Level 3',1.5642,103.6391,50);
|
| 91 |
+
INSERT INTO `classrooms` VALUES(36,'N28-352-01','N28 - Level 3',1.5643,103.6391,50);
|
| 92 |
+
INSERT INTO `classrooms` VALUES(37,'N28-321-02','N28 - Level 3',1.5644,103.6391,50);
|
| 93 |
+
INSERT INTO `classrooms` VALUES(38,'N28-329-01','N28 - Level 3',1.5645,103.6391,50);
|
| 94 |
+
INSERT INTO `classrooms` VALUES(39,'N28-350-03','N28 - Level 3',1.5646,103.6391,50);
|
| 95 |
+
INSERT INTO `classrooms` VALUES(40,'N28-422-01','N28 - Level 4',1.5647,103.6391,50);
|
| 96 |
+
INSERT INTO `classrooms` VALUES(41,'N28-423-01','N28 - Level 4',1.5638,103.6392,50);
|
| 97 |
+
INSERT INTO `classrooms` VALUES(42,'L50-DK3','Block L50 - Centre Point',1.5639,103.6392,50);
|
| 98 |
+
INSERT INTO `classrooms` VALUES(43,'P19-DK4','Block P19 - FKE',1.564,103.6392,50);
|
| 99 |
+
INSERT INTO `classrooms` VALUES(44,'P19-DK5','Block P19 - FKE',1.5641,103.6392,50);
|
| 100 |
+
INSERT INTO `classrooms` VALUES(45,'P19-DK6','Block P19 - FKE',1.5642,103.6392,50);
|
| 101 |
+
INSERT INTO `classrooms` VALUES(46,'PUTERI-COURT','Puteri Court Condominium (Home Demo)',3.128024,101.765479,1000);
|
| 102 |
+
CREATE TABLE students (
|
| 103 |
+
id INTEGER NOT NULL,
|
| 104 |
+
user_id INTEGER,
|
| 105 |
+
student_id VARCHAR(50) NOT NULL,
|
| 106 |
+
name VARCHAR(100) NOT NULL,
|
| 107 |
+
email VARCHAR(100),
|
| 108 |
+
face_encoding TEXT,
|
| 109 |
+
registered_at DATETIME,
|
| 110 |
+
is_active BOOLEAN,
|
| 111 |
+
PRIMARY KEY (id),
|
| 112 |
+
UNIQUE (user_id),
|
| 113 |
+
FOREIGN KEY(user_id) REFERENCES users (id),
|
| 114 |
+
UNIQUE (student_id),
|
| 115 |
+
UNIQUE (email)
|
| 116 |
+
);
|
| 117 |
+
INSERT INTO `students` VALUES(1,1,'B24CS0011','CHUA LIN WEI','chuawei@graduate.utm.my','[15.0, 180.0, 15.0, 242.0, 74.0, 75.0, 180.0, 242.0, 127.0, 136.0, 138.0, 179.0, 93.0, 68.0, 122.0, 148.0, 34.0, 224.0, 223.0, 39.0, 58.0, 228.0, 180.0, 183.0, 91.0, 67.0, 219.0, 4.0, 131.0, 147.0, 136.0, 58.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]','2025-11-22 16:33:50.673821',1);
|
| 118 |
+
CREATE TABLE users (
|
| 119 |
+
id INTEGER NOT NULL,
|
| 120 |
+
matric_no VARCHAR(50) NOT NULL,
|
| 121 |
+
name VARCHAR(100) NOT NULL,
|
| 122 |
+
email VARCHAR(100),
|
| 123 |
+
password_hash VARCHAR(255) NOT NULL,
|
| 124 |
+
role VARCHAR(20) NOT NULL,
|
| 125 |
+
is_active BOOLEAN,
|
| 126 |
+
created_at DATETIME,
|
| 127 |
+
PRIMARY KEY (id),
|
| 128 |
+
UNIQUE (matric_no),
|
| 129 |
+
UNIQUE (email)
|
| 130 |
+
);
|
| 131 |
+
INSERT INTO `users` VALUES(1,'B24CS0011','CHUA LIN WEI','chuawei@graduate.utm.my','scrypt:32768:8:1$hE1y6RkAGBX68GXd$0f613cc449e9b0f9bf2be44cc9914af63d03bc6af78c96baf25081d085e8e64fdd001d01b286afe3cde65fba0ce980d5666c4a233057c7937f6924302e38cd84','student',1,'2025-11-22 16:33:49.914535');
|
| 132 |
+
INSERT INTO `users` VALUES(2,'L001','ZHANG LING HE','miaofifi146@gmail.com','scrypt:32768:8:1$0Lde9MkTKZHggMvp$fd98cbd8955f87ffc01b1e383429b350d50ff4c3e5b05ac7f39bf25dac99aa1f0858f8c5c562decc81c297fd5b1f98bbce7ec69027875bd9882eb052e07d777a','lecturer',1,'2025-11-22 16:34:27.215798');
|
| 133 |
+
INSERT INTO `users` VALUES(3,'L9999','Test Lecturer',NULL,'scrypt:32768:8:1$mVC87j8AGr8uHDsu$4b3a45675a2731d350460403556460e2ebbdd85fcdb204cc9ad81695a0f573f20c36405be345ead28baedc30f21f4cf3fc8e32c14bfc3b83f89d2d4a0edf74bf','lecturer',1,'2025-11-22 17:20:16.122417');
|
| 134 |
+
COMMIT;
|
auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication helper functions and decorators
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from flask import session, redirect, url_for, flash
|
| 7 |
+
from models import User
|
| 8 |
+
|
| 9 |
+
def login_required(f):
|
| 10 |
+
"""Decorator to require login for a route"""
|
| 11 |
+
@wraps(f)
|
| 12 |
+
def decorated_function(*args, **kwargs):
|
| 13 |
+
if 'user_id' not in session:
|
| 14 |
+
flash('Please login to access this page', 'error')
|
| 15 |
+
return redirect(url_for('login_page'))
|
| 16 |
+
return f(*args, **kwargs)
|
| 17 |
+
return decorated_function
|
| 18 |
+
|
| 19 |
+
def role_required(role):
|
| 20 |
+
"""Decorator to require specific role for a route"""
|
| 21 |
+
def decorator(f):
|
| 22 |
+
@wraps(f)
|
| 23 |
+
def decorated_function(*args, **kwargs):
|
| 24 |
+
if 'user_id' not in session:
|
| 25 |
+
flash('Please login to access this page', 'error')
|
| 26 |
+
return redirect(url_for('login_page'))
|
| 27 |
+
|
| 28 |
+
user = User.query.get(session['user_id'])
|
| 29 |
+
if not user or user.role != role:
|
| 30 |
+
flash(f'Access denied. This page is for {role}s only.', 'error')
|
| 31 |
+
return redirect(url_for('index'))
|
| 32 |
+
|
| 33 |
+
return f(*args, **kwargs)
|
| 34 |
+
return decorated_function
|
| 35 |
+
return decorator
|
| 36 |
+
|
| 37 |
+
def get_current_user():
|
| 38 |
+
"""Get currently logged in user"""
|
| 39 |
+
if 'user_id' in session:
|
| 40 |
+
return User.query.get(session['user_id'])
|
| 41 |
+
return None
|
clear_database.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Clear all database data and reinitialize with fresh tables
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from app import app, db
|
| 6 |
+
from models import User, Student, Classroom, AttendanceSession, AttendanceRecord
|
| 7 |
+
|
| 8 |
+
def clear_database():
|
| 9 |
+
"""Drop all tables and recreate them"""
|
| 10 |
+
|
| 11 |
+
with app.app_context():
|
| 12 |
+
print("Clearing database...")
|
| 13 |
+
print("="*70)
|
| 14 |
+
|
| 15 |
+
# Drop all tables
|
| 16 |
+
db.drop_all()
|
| 17 |
+
print("[OK] Dropped all tables")
|
| 18 |
+
|
| 19 |
+
# Recreate all tables
|
| 20 |
+
db.create_all()
|
| 21 |
+
print("[OK] Created fresh tables")
|
| 22 |
+
|
| 23 |
+
print("="*70)
|
| 24 |
+
print("Database cleared successfully!")
|
| 25 |
+
print("\nAll data has been removed:")
|
| 26 |
+
print(" - Users: 0")
|
| 27 |
+
print(" - Students: 0")
|
| 28 |
+
print(" - Classrooms: 0")
|
| 29 |
+
print(" - Attendance Sessions: 0")
|
| 30 |
+
print(" - Attendance Records: 0")
|
| 31 |
+
print("="*70)
|
| 32 |
+
print("\nNext steps:")
|
| 33 |
+
print("1. Run: python add_utm_venues.py (to add classrooms)")
|
| 34 |
+
print("2. Run: python add_home_venue.py (to add Puteri Court)")
|
| 35 |
+
print("3. Register new accounts at: http://localhost:5000/register")
|
| 36 |
+
print("="*70)
|
| 37 |
+
|
| 38 |
+
if __name__ == '__main__':
|
| 39 |
+
clear_database()
|
config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
class Config:
|
| 4 |
+
"""Configuration settings for Attendr Smart Attendance System"""
|
| 5 |
+
|
| 6 |
+
# Flask settings
|
| 7 |
+
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
| 8 |
+
|
| 9 |
+
# Database settings
|
| 10 |
+
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
| 11 |
+
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
| 12 |
+
'sqlite:///' + os.path.join(BASE_DIR, 'attendr.db')
|
| 13 |
+
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
| 14 |
+
|
| 15 |
+
# Upload folder settings
|
| 16 |
+
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
|
| 17 |
+
FACE_ENCODINGS_FOLDER = os.path.join(UPLOAD_FOLDER, 'face_encodings')
|
| 18 |
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
|
| 19 |
+
|
| 20 |
+
# Face recognition settings
|
| 21 |
+
FACE_RECOGNITION_TOLERANCE = 0.6 # Lower = more strict (0.6 is default)
|
| 22 |
+
FACE_DETECTION_MODEL = 'hog' # 'hog' is faster, 'cnn' is more accurate
|
| 23 |
+
|
| 24 |
+
# Geolocation settings
|
| 25 |
+
GEOLOCATION_RADIUS_METERS = 50 # Classroom detection radius
|
| 26 |
+
|
| 27 |
+
# Attendance Code Settings
|
| 28 |
+
CODE_LENGTH = 6 # Length of attendance code
|
| 29 |
+
CODE_REFRESH_INTERVAL = 10 # Code refresh interval in seconds (10 seconds for demo)
|
| 30 |
+
|
| 31 |
+
# Session Settings
|
| 32 |
+
SESSION_DURATION_HOURS = 3 # Default session duration
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def init_app(app):
|
| 36 |
+
"""Initialize application with config"""
|
| 37 |
+
# Create upload folders if they don't exist
|
| 38 |
+
os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True)
|
| 39 |
+
os.makedirs(Config.FACE_ENCODINGS_FOLDER, exist_ok=True)
|
dump_db_to_sql.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sqlite3
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
def dump_sqlite_to_sql(db_path, output_path):
|
| 6 |
+
# Check if DB exists
|
| 7 |
+
if not os.path.exists(db_path):
|
| 8 |
+
print(f"Error: Database file '{db_path}' not found.")
|
| 9 |
+
return
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
# Connect to the database
|
| 13 |
+
conn = sqlite3.connect(db_path)
|
| 14 |
+
|
| 15 |
+
# Open the output file
|
| 16 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 17 |
+
# Iterate through the dump and write to file
|
| 18 |
+
for line in conn.iterdump():
|
| 19 |
+
f.write('%s\n' % line)
|
| 20 |
+
|
| 21 |
+
print(f"Successfully dumped '{db_path}' to '{output_path}'.")
|
| 22 |
+
|
| 23 |
+
conn.close()
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"An error occurred: {e}")
|
| 26 |
+
|
| 27 |
+
if __name__ == "__main__":
|
| 28 |
+
db_file = "attendr.db"
|
| 29 |
+
sql_file = "attendr.sql"
|
| 30 |
+
|
| 31 |
+
# Get absolute paths
|
| 32 |
+
current_dir = os.getcwd()
|
| 33 |
+
db_path = os.path.join(current_dir, db_file)
|
| 34 |
+
output_path = os.path.join(current_dir, sql_file)
|
| 35 |
+
|
| 36 |
+
print(f"Dumping {db_path}...")
|
| 37 |
+
dump_sqlite_to_sql(db_path, output_path)
|
face_recognition_module.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mock Face Recognition Module for Testing (Windows-friendly)
|
| 3 |
+
This is a simplified version that doesn't require dlib/face_recognition
|
| 4 |
+
For production, install the full face_recognition library
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import cv2
|
| 9 |
+
from config import Config
|
| 10 |
+
import logging
|
| 11 |
+
import hashlib
|
| 12 |
+
|
| 13 |
+
# Set up logging
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
logger.warning("Using MOCK face recognition module - for testing only!")
|
| 18 |
+
logger.warning("Install face_recognition library for production use")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FaceRecognitionModule:
|
| 22 |
+
"""Mock face recognition for testing without dlib dependencies"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, tolerance=None, model='hog'):
|
| 25 |
+
self.tolerance = tolerance or Config.FACE_RECOGNITION_TOLERANCE
|
| 26 |
+
self.model = model
|
| 27 |
+
logger.info(f"Mock Face Recognition Module initialized")
|
| 28 |
+
|
| 29 |
+
def detect_faces(self, image_array):
|
| 30 |
+
"""Mock face detection using OpenCV Haar Cascades"""
|
| 31 |
+
try:
|
| 32 |
+
# Convert to grayscale
|
| 33 |
+
gray = cv2.cvtColor(image_array, cv2.COLOR_RGB2GRAY)
|
| 34 |
+
|
| 35 |
+
# Load Haar cascade for face detection
|
| 36 |
+
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
| 37 |
+
|
| 38 |
+
# Detect faces
|
| 39 |
+
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
|
| 40 |
+
|
| 41 |
+
# Convert to format similar to face_recognition library
|
| 42 |
+
face_locations = [(y, x+w, y+h, x) for (x, y, w, h) in faces]
|
| 43 |
+
|
| 44 |
+
logger.info(f"Detected {len(face_locations)} face(s)")
|
| 45 |
+
return face_locations
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.error(f"Face detection error: {str(e)}")
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
def generate_face_encoding(self, image_array):
|
| 51 |
+
"""
|
| 52 |
+
Mock encoding generation using image hash
|
| 53 |
+
In production, this would use deep learning-based face encodings
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
face_locations = self.detect_faces(image_array)
|
| 57 |
+
|
| 58 |
+
if len(face_locations) == 0:
|
| 59 |
+
return False, "No face detected in the image. Please ensure your face is clearly visible."
|
| 60 |
+
|
| 61 |
+
if len(face_locations) > 1:
|
| 62 |
+
return False, "Multiple faces detected. Please ensure only one person is in the frame."
|
| 63 |
+
|
| 64 |
+
# Create a simple "encoding" using image hash (for testing only)
|
| 65 |
+
# In production, this would be a 128-dimensional face encoding
|
| 66 |
+
top, right, bottom, left = face_locations[0]
|
| 67 |
+
face_region = image_array[top:bottom, left:right]
|
| 68 |
+
|
| 69 |
+
# Resize to standard size
|
| 70 |
+
face_resized = cv2.resize(face_region, (100, 100))
|
| 71 |
+
|
| 72 |
+
# Create hash-based encoding (mock)
|
| 73 |
+
face_bytes = face_resized.tobytes()
|
| 74 |
+
hash_obj = hashlib.sha256(face_bytes)
|
| 75 |
+
hash_digest = hash_obj.digest() # Get bytes directly
|
| 76 |
+
|
| 77 |
+
# Convert to numpy array (128 dimensions to match real encodings)
|
| 78 |
+
# Use hash bytes to create 128-dimensional vector
|
| 79 |
+
encoding = np.array([float(b) for b in hash_digest[:128]] + [0.0] * (128 - len(hash_digest[:128])))
|
| 80 |
+
|
| 81 |
+
logger.info("Mock face encoding generated")
|
| 82 |
+
return True, encoding
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Face encoding error: {str(e)}")
|
| 86 |
+
return False, f"Face encoding failed: {str(e)}"
|
| 87 |
+
|
| 88 |
+
def verify_face(self, captured_encoding, stored_encoding):
|
| 89 |
+
"""
|
| 90 |
+
Mock face verification using encoding similarity
|
| 91 |
+
In production, this would use Euclidean distance between face encodings
|
| 92 |
+
"""
|
| 93 |
+
try:
|
| 94 |
+
if captured_encoding is None or stored_encoding is None:
|
| 95 |
+
return False, 0.0
|
| 96 |
+
|
| 97 |
+
# Convert to numpy arrays
|
| 98 |
+
if not isinstance(captured_encoding, np.ndarray):
|
| 99 |
+
captured_encoding = np.array(captured_encoding)
|
| 100 |
+
if not isinstance(stored_encoding, np.ndarray):
|
| 101 |
+
stored_encoding = np.array(stored_encoding)
|
| 102 |
+
|
| 103 |
+
# Calculate similarity (mock - using correlation)
|
| 104 |
+
# In production, this would be face_recognition.face_distance()
|
| 105 |
+
correlation = np.corrcoef(captured_encoding, stored_encoding)[0, 1]
|
| 106 |
+
|
| 107 |
+
# Convert to distance (0 = identical, 1 = completely different)
|
| 108 |
+
distance = 1 - abs(correlation)
|
| 109 |
+
|
| 110 |
+
# Calculate confidence
|
| 111 |
+
confidence = (1 - distance) * 100
|
| 112 |
+
|
| 113 |
+
# Check if match
|
| 114 |
+
is_match = distance <= self.tolerance
|
| 115 |
+
|
| 116 |
+
logger.info(f"Mock verification: match={is_match}, confidence={confidence:.2f}%")
|
| 117 |
+
|
| 118 |
+
return is_match, confidence
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Face verification error: {str(e)}")
|
| 122 |
+
return False, 0.0
|
| 123 |
+
|
| 124 |
+
def register_face(self, image_array):
|
| 125 |
+
"""Complete face registration"""
|
| 126 |
+
try:
|
| 127 |
+
face_locations = self.detect_faces(image_array)
|
| 128 |
+
|
| 129 |
+
if len(face_locations) == 0:
|
| 130 |
+
return False, "No face detected. Please ensure your face is clearly visible and well-lit.", None
|
| 131 |
+
|
| 132 |
+
if len(face_locations) > 1:
|
| 133 |
+
return False, "Multiple faces detected. Please ensure only one person is in the frame.", None
|
| 134 |
+
|
| 135 |
+
success, result = self.generate_face_encoding(image_array)
|
| 136 |
+
|
| 137 |
+
if success:
|
| 138 |
+
return True, result, face_locations[0]
|
| 139 |
+
else:
|
| 140 |
+
return False, result, None
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"Face registration error: {str(e)}")
|
| 144 |
+
return False, f"Registration failed: {str(e)}", None
|
| 145 |
+
|
| 146 |
+
def compare_faces_batch(self, known_encodings, face_encoding_to_check):
|
| 147 |
+
"""Compare against multiple encodings"""
|
| 148 |
+
try:
|
| 149 |
+
matches = []
|
| 150 |
+
for known_encoding in known_encodings:
|
| 151 |
+
is_match, _ = self.verify_face(face_encoding_to_check, known_encoding)
|
| 152 |
+
matches.append(is_match)
|
| 153 |
+
return matches
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Batch comparison error: {str(e)}")
|
| 156 |
+
return []
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# Singleton instance
|
| 160 |
+
face_recognition_module = FaceRecognitionModule()
|
face_recognition_module_PRODUCTION.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Production Face Recognition Module for Attendr
|
| 3 |
+
This is the REAL implementation using the face_recognition library
|
| 4 |
+
Use this file after installing dlib and face_recognition
|
| 5 |
+
|
| 6 |
+
To activate:
|
| 7 |
+
1. Install dlib and face_recognition
|
| 8 |
+
2. Rename face_recognition_module.py to face_recognition_module_MOCK.py
|
| 9 |
+
3. Rename this file to face_recognition_module.py
|
| 10 |
+
4. Restart the Flask application
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import face_recognition
|
| 14 |
+
import numpy as np
|
| 15 |
+
from config import Config
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
# Set up logging
|
| 19 |
+
logging.basicConfig(level=logging.INFO)
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FaceRecognitionModule:
|
| 24 |
+
"""Handles all face recognition operations using production library"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, tolerance=None, model='hog'):
|
| 27 |
+
"""
|
| 28 |
+
Initialize face recognition module
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
tolerance: Face matching tolerance (lower = more strict)
|
| 32 |
+
model: Detection model ('hog' or 'cnn')
|
| 33 |
+
"""
|
| 34 |
+
self.tolerance = tolerance or Config.FACE_RECOGNITION_TOLERANCE
|
| 35 |
+
self.model = model or Config.FACE_DETECTION_MODEL
|
| 36 |
+
logger.info(f"PRODUCTION Face Recognition Module initialized with tolerance={self.tolerance}, model={self.model}")
|
| 37 |
+
|
| 38 |
+
def detect_faces(self, image_array):
|
| 39 |
+
"""
|
| 40 |
+
Detect faces in an image
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
image_array: numpy array of the image
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
List of face locations [(top, right, bottom, left), ...]
|
| 47 |
+
"""
|
| 48 |
+
try:
|
| 49 |
+
face_locations = face_recognition.face_locations(image_array, model=self.model)
|
| 50 |
+
logger.info(f"Detected {len(face_locations)} face(s) in image")
|
| 51 |
+
return face_locations
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Face detection error: {str(e)}")
|
| 54 |
+
return []
|
| 55 |
+
|
| 56 |
+
def generate_face_encoding(self, image_array):
|
| 57 |
+
"""
|
| 58 |
+
Generate face encoding from an image
|
| 59 |
+
Implements: CapturedFace(x) - captures and encodes the face
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
image_array: numpy array of the image
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
tuple: (success, encoding or error_message)
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
# Detect faces first
|
| 69 |
+
face_locations = self.detect_faces(image_array)
|
| 70 |
+
|
| 71 |
+
if len(face_locations) == 0:
|
| 72 |
+
return False, "No face detected in the image. Please ensure your face is clearly visible."
|
| 73 |
+
|
| 74 |
+
if len(face_locations) > 1:
|
| 75 |
+
return False, "Multiple faces detected. Please ensure only one person is in the frame."
|
| 76 |
+
|
| 77 |
+
# Generate encoding
|
| 78 |
+
encodings = face_recognition.face_encodings(image_array, face_locations)
|
| 79 |
+
|
| 80 |
+
if len(encodings) == 0:
|
| 81 |
+
return False, "Failed to generate face encoding. Please try again with better lighting."
|
| 82 |
+
|
| 83 |
+
encoding = encodings[0]
|
| 84 |
+
logger.info("Face encoding generated successfully")
|
| 85 |
+
return True, encoding
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"Face encoding error: {str(e)}")
|
| 89 |
+
return False, f"Face encoding failed: {str(e)}"
|
| 90 |
+
|
| 91 |
+
def verify_face(self, captured_encoding, stored_encoding):
|
| 92 |
+
"""
|
| 93 |
+
Verify if captured face matches stored encoding
|
| 94 |
+
Implements: MatchStored(x) → FaceMatch(x)
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
captured_encoding: Face encoding from live capture
|
| 98 |
+
stored_encoding: Stored face encoding from database
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
tuple: (is_match: bool, confidence: float)
|
| 102 |
+
"""
|
| 103 |
+
try:
|
| 104 |
+
if captured_encoding is None or stored_encoding is None:
|
| 105 |
+
logger.warning("One or both encodings are None")
|
| 106 |
+
return False, 0.0
|
| 107 |
+
|
| 108 |
+
# Convert to numpy arrays if needed
|
| 109 |
+
if not isinstance(captured_encoding, np.ndarray):
|
| 110 |
+
captured_encoding = np.array(captured_encoding)
|
| 111 |
+
if not isinstance(stored_encoding, np.ndarray):
|
| 112 |
+
stored_encoding = np.array(stored_encoding)
|
| 113 |
+
|
| 114 |
+
# Calculate face distance (lower = more similar)
|
| 115 |
+
face_distance = face_recognition.face_distance([stored_encoding], captured_encoding)[0]
|
| 116 |
+
|
| 117 |
+
# Convert distance to confidence percentage
|
| 118 |
+
confidence = (1 - face_distance) * 100
|
| 119 |
+
|
| 120 |
+
# Check if match is within tolerance
|
| 121 |
+
is_match = face_distance <= self.tolerance
|
| 122 |
+
|
| 123 |
+
logger.info(f"Face verification: match={is_match}, confidence={confidence:.2f}%, distance={face_distance:.4f}")
|
| 124 |
+
|
| 125 |
+
return is_match, confidence
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Face verification error: {str(e)}")
|
| 129 |
+
return False, 0.0
|
| 130 |
+
|
| 131 |
+
def register_face(self, image_array):
|
| 132 |
+
"""
|
| 133 |
+
Complete face registration process
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
image_array: numpy array of the image
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
tuple: (success, encoding or error_message, face_location)
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
# Detect faces
|
| 143 |
+
face_locations = self.detect_faces(image_array)
|
| 144 |
+
|
| 145 |
+
if len(face_locations) == 0:
|
| 146 |
+
return False, "No face detected. Please ensure your face is clearly visible and well-lit.", None
|
| 147 |
+
|
| 148 |
+
if len(face_locations) > 1:
|
| 149 |
+
return False, "Multiple faces detected. Please ensure only one person is in the frame.", None
|
| 150 |
+
|
| 151 |
+
# Generate encoding
|
| 152 |
+
success, result = self.generate_face_encoding(image_array)
|
| 153 |
+
|
| 154 |
+
if success:
|
| 155 |
+
return True, result, face_locations[0]
|
| 156 |
+
else:
|
| 157 |
+
return False, result, None
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"Face registration error: {str(e)}")
|
| 161 |
+
return False, f"Registration failed: {str(e)}", None
|
| 162 |
+
|
| 163 |
+
def compare_faces_batch(self, known_encodings, face_encoding_to_check):
|
| 164 |
+
"""
|
| 165 |
+
Compare a face encoding against multiple known encodings
|
| 166 |
+
Useful for identifying which student from a list
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
known_encodings: List of known face encodings
|
| 170 |
+
face_encoding_to_check: Face encoding to compare
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
List of boolean matches
|
| 174 |
+
"""
|
| 175 |
+
try:
|
| 176 |
+
if not isinstance(face_encoding_to_check, np.ndarray):
|
| 177 |
+
face_encoding_to_check = np.array(face_encoding_to_check)
|
| 178 |
+
|
| 179 |
+
matches = face_recognition.compare_faces(
|
| 180 |
+
known_encodings,
|
| 181 |
+
face_encoding_to_check,
|
| 182 |
+
tolerance=self.tolerance
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
return matches
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Batch face comparison error: {str(e)}")
|
| 189 |
+
return []
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# Singleton instance
|
| 193 |
+
face_recognition_module = FaceRecognitionModule()
|
fix_sql_quotes.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
file_path = 'attendr.sql'
|
| 5 |
+
|
| 6 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 7 |
+
content = f.read()
|
| 8 |
+
|
| 9 |
+
# Replace "table_name" with `table_name` in INSERT INTO statements
|
| 10 |
+
# Regex looks for: INSERT INTO "word"
|
| 11 |
+
# Replaces with: INSERT INTO `word`
|
| 12 |
+
# Or I can just remove the quotes since table names are safe. Let's remove them to be safe across different modes if possible, but backticks are safer for MySQL.
|
| 13 |
+
# Let's use backticks.
|
| 14 |
+
|
| 15 |
+
def replace_quotes(match):
|
| 16 |
+
return f'INSERT INTO `{match.group(1)}`'
|
| 17 |
+
|
| 18 |
+
new_content = re.sub(r'INSERT INTO "([^"]+)"', replace_quotes, content)
|
| 19 |
+
|
| 20 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 21 |
+
f.write(new_content)
|
| 22 |
+
|
| 23 |
+
print("Fixed quotes in attendr.sql")
|
geolocation_module.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Geolocation Verification Module for Attendr
|
| 3 |
+
Implements KR Rule #2: Geolocation Verification
|
| 4 |
+
∀x[(Student(x) ∧ IsWithinAllowedArea(x)) → VerifiedLocation(x)]
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from utils import haversine_distance, validate_coordinates
|
| 8 |
+
from models import Classroom, db
|
| 9 |
+
from config import Config
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
# Set up logging
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GeolocationModule:
|
| 18 |
+
"""Handles all geolocation verification operations"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, default_radius=None):
|
| 21 |
+
"""
|
| 22 |
+
Initialize geolocation module
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
default_radius: Default radius in meters for geofencing
|
| 26 |
+
"""
|
| 27 |
+
self.default_radius = default_radius or Config.GEOLOCATION_RADIUS_METERS
|
| 28 |
+
logger.info(f"Geolocation Module initialized with default radius={self.default_radius}m")
|
| 29 |
+
|
| 30 |
+
def get_classroom_location(self, classroom_id):
|
| 31 |
+
"""
|
| 32 |
+
Get classroom GPS coordinates and radius
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
classroom_id: ID of the classroom
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
tuple: (latitude, longitude, radius) or (None, None, None) if not found
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
classroom = Classroom.query.get(classroom_id)
|
| 42 |
+
|
| 43 |
+
if not classroom:
|
| 44 |
+
logger.warning(f"Classroom {classroom_id} not found")
|
| 45 |
+
return None, None, None
|
| 46 |
+
|
| 47 |
+
radius = classroom.radius_meters or self.default_radius
|
| 48 |
+
logger.info(f"Classroom {classroom.name}: lat={classroom.latitude}, lon={classroom.longitude}, radius={radius}m")
|
| 49 |
+
|
| 50 |
+
return classroom.latitude, classroom.longitude, radius
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Error fetching classroom location: {str(e)}")
|
| 54 |
+
return None, None, None
|
| 55 |
+
|
| 56 |
+
def calculate_distance(self, lat1, lon1, lat2, lon2):
|
| 57 |
+
"""
|
| 58 |
+
Calculate distance between two GPS coordinates
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
lat1, lon1: First coordinate
|
| 62 |
+
lat2, lon2: Second coordinate
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Distance in meters
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
distance = haversine_distance(lat1, lon1, lat2, lon2)
|
| 69 |
+
logger.info(f"Distance calculated: {distance:.2f} meters")
|
| 70 |
+
return distance
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Distance calculation error: {str(e)}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
def is_within_allowed_area(self, student_lat, student_lon, classroom_id):
|
| 76 |
+
"""
|
| 77 |
+
Check if student is within allowed classroom area
|
| 78 |
+
Implements: IsWithinAllowedArea(x)
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
student_lat: Student's latitude
|
| 82 |
+
student_lon: Student's longitude
|
| 83 |
+
classroom_id: ID of the classroom
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
tuple: (is_within: bool, distance: float, error_message: str or None)
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
# Validate student coordinates
|
| 90 |
+
valid, error = validate_coordinates(student_lat, student_lon)
|
| 91 |
+
if not valid:
|
| 92 |
+
logger.warning(f"Invalid student coordinates: {error}")
|
| 93 |
+
return False, None, error
|
| 94 |
+
|
| 95 |
+
# Get classroom location
|
| 96 |
+
class_lat, class_lon, radius = self.get_classroom_location(classroom_id)
|
| 97 |
+
|
| 98 |
+
if class_lat is None:
|
| 99 |
+
return False, None, "Classroom location not found"
|
| 100 |
+
|
| 101 |
+
# Calculate distance
|
| 102 |
+
distance = self.calculate_distance(
|
| 103 |
+
float(student_lat),
|
| 104 |
+
float(student_lon),
|
| 105 |
+
class_lat,
|
| 106 |
+
class_lon
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if distance is None:
|
| 110 |
+
return False, None, "Failed to calculate distance"
|
| 111 |
+
|
| 112 |
+
# Check if within radius
|
| 113 |
+
is_within = distance <= radius
|
| 114 |
+
|
| 115 |
+
logger.info(f"Location verification: within_area={is_within}, distance={distance:.2f}m, allowed_radius={radius}m")
|
| 116 |
+
|
| 117 |
+
if is_within:
|
| 118 |
+
return True, distance, None
|
| 119 |
+
else:
|
| 120 |
+
return False, distance, f"You are {distance:.0f}m away from the classroom (max allowed: {radius}m)"
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"Location verification error: {str(e)}")
|
| 124 |
+
return False, None, f"Location verification failed: {str(e)}"
|
| 125 |
+
|
| 126 |
+
def verify_location(self, student_lat, student_lon, classroom_id):
|
| 127 |
+
"""
|
| 128 |
+
Complete location verification process
|
| 129 |
+
Implements: Student(x) ∧ IsWithinAllowedArea(x) → VerifiedLocation(x)
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
student_lat: Student's latitude
|
| 133 |
+
student_lon: Student's longitude
|
| 134 |
+
classroom_id: ID of the classroom
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
dict: Verification result with status, distance, and message
|
| 138 |
+
"""
|
| 139 |
+
is_within, distance, error = self.is_within_allowed_area(
|
| 140 |
+
student_lat,
|
| 141 |
+
student_lon,
|
| 142 |
+
classroom_id
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
result = {
|
| 146 |
+
'verified': is_within,
|
| 147 |
+
'distance': distance,
|
| 148 |
+
'message': None,
|
| 149 |
+
'error': error
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if is_within:
|
| 153 |
+
result['message'] = f"Location verified! You are {distance:.0f}m from the classroom."
|
| 154 |
+
elif error:
|
| 155 |
+
result['message'] = error
|
| 156 |
+
|
| 157 |
+
return result
|
| 158 |
+
|
| 159 |
+
def get_all_classrooms(self):
|
| 160 |
+
"""
|
| 161 |
+
Get all available classrooms
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
List of classroom dictionaries
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
classrooms = Classroom.query.all()
|
| 168 |
+
return [classroom.to_dict() for classroom in classrooms]
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"Error fetching classrooms: {str(e)}")
|
| 171 |
+
return []
|
| 172 |
+
|
| 173 |
+
def add_classroom(self, name, building, latitude, longitude, radius_meters=None):
|
| 174 |
+
"""
|
| 175 |
+
Add a new classroom to the system
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
name: Classroom name
|
| 179 |
+
building: Building name
|
| 180 |
+
latitude: GPS latitude
|
| 181 |
+
longitude: GPS longitude
|
| 182 |
+
radius_meters: Geofencing radius (optional)
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
tuple: (success, classroom_dict or error_message)
|
| 186 |
+
"""
|
| 187 |
+
try:
|
| 188 |
+
# Validate coordinates
|
| 189 |
+
valid, error = validate_coordinates(latitude, longitude)
|
| 190 |
+
if not valid:
|
| 191 |
+
return False, error
|
| 192 |
+
|
| 193 |
+
# Create classroom
|
| 194 |
+
classroom = Classroom(
|
| 195 |
+
name=name,
|
| 196 |
+
building=building,
|
| 197 |
+
latitude=float(latitude),
|
| 198 |
+
longitude=float(longitude),
|
| 199 |
+
radius_meters=radius_meters or self.default_radius
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
db.session.add(classroom)
|
| 203 |
+
db.session.commit()
|
| 204 |
+
|
| 205 |
+
logger.info(f"Classroom added: {name} in {building}")
|
| 206 |
+
return True, classroom.to_dict()
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
db.session.rollback()
|
| 210 |
+
logger.error(f"Error adding classroom: {str(e)}")
|
| 211 |
+
return False, f"Failed to add classroom: {str(e)}"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# Singleton instance
|
| 215 |
+
geolocation_module = GeolocationModule()
|
index.html
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Attendr - Smart Attendance System</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link
|
| 12 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 13 |
+
rel="stylesheet">
|
| 14 |
+
</head>
|
| 15 |
+
|
| 16 |
+
<body>
|
| 17 |
+
<!-- Navigation -->
|
| 18 |
+
<!-- Navigation -->
|
| 19 |
+
{% include '_navbar.html' %}
|
| 20 |
+
|
| 21 |
+
<!-- Hero Section -->
|
| 22 |
+
<section class="section">
|
| 23 |
+
<div class="container">
|
| 24 |
+
<div class="text-center" style="max-width: 900px; margin: 0 auto;">
|
| 25 |
+
<h1 class="fade-in">Smart Attendance System</h1>
|
| 26 |
+
<p class="fade-in" style="font-size: 1.25rem; margin-bottom: 2rem; color: var(--color-text-secondary);">
|
| 27 |
+
AI-powered attendance verification using facial recognition, geolocation, and auto-refresh codes
|
| 28 |
+
</p>
|
| 29 |
+
|
| 30 |
+
<!-- Feature Highlights -->
|
| 31 |
+
<div class="grid grid-3 mt-3 mb-3">
|
| 32 |
+
<div class="glass-card fade-in" style="animation-delay: 0.1s;">
|
| 33 |
+
<div style="font-size: 3rem; margin-bottom: 1rem;">🎭</div>
|
| 34 |
+
<h3>Face Recognition</h3>
|
| 35 |
+
<p>Biometric verification ensures only registered students can mark attendance</p>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="glass-card fade-in" style="animation-delay: 0.2s;">
|
| 39 |
+
<div style="font-size: 3rem; margin-bottom: 1rem;">📍</div>
|
| 40 |
+
<h3>GPS Verification</h3>
|
| 41 |
+
<p>Location-based validation confirms physical presence in the classroom</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="glass-card fade-in" style="animation-delay: 0.3s;">
|
| 45 |
+
<div style="font-size: 3rem; margin-bottom: 1rem;">🔄</div>
|
| 46 |
+
<h3>Auto-Refresh Codes</h3>
|
| 47 |
+
<p>Time-limited codes prevent sharing and proxy attendance</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- CTA Buttons -->
|
| 52 |
+
<div class="flex-center gap-2 mt-3">
|
| 53 |
+
<a href="/student" class="btn btn-primary">
|
| 54 |
+
<span>👤</span>
|
| 55 |
+
<span>Student Portal</span>
|
| 56 |
+
</a>
|
| 57 |
+
<a href="/lecturer" class="btn btn-secondary">
|
| 58 |
+
<span>👨🏫</span>
|
| 59 |
+
<span>Lecturer Dashboard</span>
|
| 60 |
+
</a>
|
| 61 |
+
<a href="/register" class="btn btn-outline">
|
| 62 |
+
<span>📝</span>
|
| 63 |
+
<span>Register</span>
|
| 64 |
+
</a>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- System Overview -->
|
| 68 |
+
<div class="glass-card mt-3 text-left">
|
| 69 |
+
<h2 class="mb-2">How It Works</h2>
|
| 70 |
+
<div class="grid grid-2 gap-2">
|
| 71 |
+
<div>
|
| 72 |
+
<h3 style="color: var(--color-primary-light);">For Students</h3>
|
| 73 |
+
<ol style="padding-left: 1.5rem; color: var(--color-text-secondary);">
|
| 74 |
+
<li style="margin-bottom: 0.5rem;">Register your account and face</li>
|
| 75 |
+
<li style="margin-bottom: 0.5rem;">Login and enable camera/GPS permissions</li>
|
| 76 |
+
<li style="margin-bottom: 0.5rem;">System verifies your face and location</li>
|
| 77 |
+
<li style="margin-bottom: 0.5rem;">Enter the attendance code from lecturer</li>
|
| 78 |
+
<li style="margin-bottom: 0.5rem;">Attendance marked instantly!</li>
|
| 79 |
+
</ol>
|
| 80 |
+
</div>
|
| 81 |
+
<div>
|
| 82 |
+
<h3 style="color: var(--color-secondary);">For Lecturers</h3>
|
| 83 |
+
<ol style="padding-left: 1.5rem; color: var(--color-text-secondary);">
|
| 84 |
+
<li style="margin-bottom: 0.5rem;">Register lecturer account</li>
|
| 85 |
+
<li style="margin-bottom: 0.5rem;">Login and create attendance session</li>
|
| 86 |
+
<li style="margin-bottom: 0.5rem;">Display auto-refreshing code to students</li>
|
| 87 |
+
<li style="margin-bottom: 0.5rem;">Monitor real-time attendance updates</li>
|
| 88 |
+
<li style="margin-bottom: 0.5rem;">Export reports for record-keeping</li>
|
| 89 |
+
</ol>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- Knowledge Representation -->
|
| 95 |
+
<div class="glass-card mt-3 text-left">
|
| 96 |
+
<h2 class="mb-2">AI Logic & Security</h2>
|
| 97 |
+
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
|
| 98 |
+
Attendr implements five Knowledge Representation (KR) rules using First-Order Logic to ensure
|
| 99 |
+
secure, fraud-proof attendance:
|
| 100 |
+
</p>
|
| 101 |
+
<div class="grid grid-2 gap-2">
|
| 102 |
+
<div class="alert alert-info">
|
| 103 |
+
<div>
|
| 104 |
+
<strong>KR Rule #1: Face Recognition</strong><br>
|
| 105 |
+
<small>∀x ((CapturedFace(x) ∧ MatchStored(x)) → FaceMatch(x))</small>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="alert alert-info">
|
| 109 |
+
<div>
|
| 110 |
+
<strong>KR Rule #2: Location Verification</strong><br>
|
| 111 |
+
<small>∀x[(Student(x) ∧ IsWithinAllowedArea(x)) → VerifiedLocation(x)]</small>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
<div class="alert alert-info">
|
| 115 |
+
<div>
|
| 116 |
+
<strong>KR Rule #3: Device Readiness</strong><br>
|
| 117 |
+
<small>∀d ((CameraOn(d) ∧ GPSOn(d)) → StartVerification(d))</small>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
<div class="alert alert-info">
|
| 121 |
+
<div>
|
| 122 |
+
<strong>KR Rule #4: Attendance Validation</strong><br>
|
| 123 |
+
<small>∀x ((FaceMatch(x) ∧ LocationValid(x)) → GrantCodeAccess(x))</small>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
<div class="alert alert-success mt-2">
|
| 128 |
+
<div>
|
| 129 |
+
<strong>KR Rule #5: Auto-Refresh Code Confirmation</strong><br>
|
| 130 |
+
<small>∀x ((ValidCodeEntry(User,x) ∧ WithinCycle(Code,x)) → MarkPresent(System,x))</small>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</section>
|
| 137 |
+
|
| 138 |
+
<script>
|
| 139 |
+
// Add stagger animation to cards
|
| 140 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 141 |
+
const cards = document.querySelectorAll('.fade-in');
|
| 142 |
+
cards.forEach((card, index) => {
|
| 143 |
+
card.style.animationDelay = `${index * 0.1}s`;
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
// Logout function
|
| 148 |
+
async function logout() {
|
| 149 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 150 |
+
|
| 151 |
+
try {
|
| 152 |
+
const response = await fetch('/api/auth/logout', {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
headers: { 'Content-Type': 'application/json' }
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
const data = await response.json();
|
| 158 |
+
|
| 159 |
+
if (data.success) {
|
| 160 |
+
alert(data.message);
|
| 161 |
+
window.location.href = '/';
|
| 162 |
+
} else {
|
| 163 |
+
alert('Logout failed');
|
| 164 |
+
}
|
| 165 |
+
} catch (error) {
|
| 166 |
+
alert('Logout failed');
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
</script>
|
| 170 |
+
</body>
|
| 171 |
+
|
| 172 |
+
</html>
|
init_db.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database initialization script for Attendr
|
| 3 |
+
Creates tables and seeds sample data
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app import app, db
|
| 7 |
+
from models import Student, Classroom, AttendanceSession, AttendanceRecord
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
def init_database():
|
| 11 |
+
"""Initialize database with tables and sample data"""
|
| 12 |
+
|
| 13 |
+
with app.app_context():
|
| 14 |
+
# Drop all tables (use with caution in production!)
|
| 15 |
+
print("Dropping existing tables...")
|
| 16 |
+
db.drop_all()
|
| 17 |
+
|
| 18 |
+
# Create all tables
|
| 19 |
+
print("Creating tables...")
|
| 20 |
+
db.create_all()
|
| 21 |
+
|
| 22 |
+
# Seed sample classrooms (UTM locations)
|
| 23 |
+
print("Seeding sample classrooms...")
|
| 24 |
+
|
| 25 |
+
classrooms = [
|
| 26 |
+
Classroom(
|
| 27 |
+
name="N28-01-01",
|
| 28 |
+
building="N28 (Faculty of Computing)",
|
| 29 |
+
latitude=1.5586, # UTM Skudai approximate coordinates
|
| 30 |
+
longitude=103.6376,
|
| 31 |
+
radius_meters=50
|
| 32 |
+
),
|
| 33 |
+
Classroom(
|
| 34 |
+
name="N28-02-03",
|
| 35 |
+
building="N28 (Faculty of Computing)",
|
| 36 |
+
latitude=1.5588,
|
| 37 |
+
longitude=103.6378,
|
| 38 |
+
radius_meters=50
|
| 39 |
+
),
|
| 40 |
+
Classroom(
|
| 41 |
+
name="V01-LT1",
|
| 42 |
+
building="V01 (Lecture Hall)",
|
| 43 |
+
latitude=1.5590,
|
| 44 |
+
longitude=103.6380,
|
| 45 |
+
radius_meters=75
|
| 46 |
+
),
|
| 47 |
+
Classroom(
|
| 48 |
+
name="C22-Lab A",
|
| 49 |
+
building="C22 (Computer Lab)",
|
| 50 |
+
latitude=1.5584,
|
| 51 |
+
longitude=103.6374,
|
| 52 |
+
radius_meters=40
|
| 53 |
+
)
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
for classroom in classrooms:
|
| 57 |
+
db.session.add(classroom)
|
| 58 |
+
|
| 59 |
+
db.session.commit()
|
| 60 |
+
print(f"[OK] Created {len(classrooms)} classrooms")
|
| 61 |
+
|
| 62 |
+
# Create a sample student for testing (without face encoding)
|
| 63 |
+
print("Creating sample student...")
|
| 64 |
+
sample_student = Student(
|
| 65 |
+
student_id="A20EC0001",
|
| 66 |
+
name="Test Student",
|
| 67 |
+
email="test@graduate.utm.my",
|
| 68 |
+
is_active=True
|
| 69 |
+
)
|
| 70 |
+
db.session.add(sample_student)
|
| 71 |
+
db.session.commit()
|
| 72 |
+
print("[OK] Created sample student (face registration required)")
|
| 73 |
+
|
| 74 |
+
print("\n" + "="*50)
|
| 75 |
+
print("Database initialization complete!")
|
| 76 |
+
print("="*50)
|
| 77 |
+
print("\nSample Classrooms:")
|
| 78 |
+
for classroom in classrooms:
|
| 79 |
+
print(f" - {classroom.name} ({classroom.building})")
|
| 80 |
+
print(f"\nSample Student: {sample_student.student_id} - {sample_student.name}")
|
| 81 |
+
print("\nNext steps:")
|
| 82 |
+
print("1. Run the Flask app: python app.py")
|
| 83 |
+
print("2. Register student faces at: http://localhost:5000/register")
|
| 84 |
+
print("3. Create attendance session at: http://localhost:5000/lecturer")
|
| 85 |
+
print("4. Mark attendance at: http://localhost:5000/student")
|
| 86 |
+
print("="*50)
|
| 87 |
+
|
| 88 |
+
if __name__ == '__main__':
|
| 89 |
+
init_database()
|
lecturer.html
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Lecturer Dashboard - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container">
|
| 22 |
+
<h1 class="text-center mb-3">Lecturer Dashboard</h1>
|
| 23 |
+
|
| 24 |
+
<div class="grid grid-2 gap-2">
|
| 25 |
+
<!-- Left Column: Session Management -->
|
| 26 |
+
<div>
|
| 27 |
+
<!-- Create Session -->
|
| 28 |
+
<div id="createSessionCard" class="glass-card mb-2">
|
| 29 |
+
<h2>Create Attendance Session</h2>
|
| 30 |
+
|
| 31 |
+
<div class="form-group">
|
| 32 |
+
<label class="form-label">Course Name</label>
|
| 33 |
+
<select id="courseName" class="form-select">
|
| 34 |
+
<option value="">Select a course...</option>
|
| 35 |
+
|
| 36 |
+
<optgroup label="CORE COURSES">
|
| 37 |
+
<option value="SECI1013 - Discrete Structure">SECI1013 - Discrete Structure
|
| 38 |
+
</option>
|
| 39 |
+
<option value="SECJ1013 - Programming Technique I">SECJ1013 - Programming
|
| 40 |
+
Technique
|
| 41 |
+
I</option>
|
| 42 |
+
<option value="SECR1013 - Digital Logic">SECR1013 - Digital Logic</option>
|
| 43 |
+
<option value="SECP1513 - Technology & Information System">SECP1513 - Technology
|
| 44 |
+
&
|
| 45 |
+
Information System</option>
|
| 46 |
+
<option value="SECI1113 - Computational Mathematics">SECI1113 - Computational
|
| 47 |
+
Mathematics</option>
|
| 48 |
+
<option value="SECI2143 - Probability & Statistical Data Analysis">SECI2143 -
|
| 49 |
+
Probability & Statistical Data Analysis</option>
|
| 50 |
+
<option value="SECJ1023 - Programming Technique II">SECJ1023 - Programming
|
| 51 |
+
Technique
|
| 52 |
+
II</option>
|
| 53 |
+
<option value="SECR1033 - Computer Organisation and Architecture">SECR1033 -
|
| 54 |
+
Computer Organisation and Architecture</option>
|
| 55 |
+
<option value="SECD2523 - Database">SECD2523 - Database</option>
|
| 56 |
+
<option value="SECD2613 - System Analysis and Design">SECD2613 - System Analysis
|
| 57 |
+
and
|
| 58 |
+
Design</option>
|
| 59 |
+
<option value="SECJ2013 - Data Structure and Algorithm">SECJ2013 - Data
|
| 60 |
+
Structure
|
| 61 |
+
and Algorithm</option>
|
| 62 |
+
<option value="SECR1213 - Network Communications">SECR1213 - Network
|
| 63 |
+
Communications
|
| 64 |
+
</option>
|
| 65 |
+
<option value="SECV2113 - Human Computer Interaction">SECV2113 - Human Computer
|
| 66 |
+
Interaction</option>
|
| 67 |
+
<option value="SECJ2203 - Software Engineering">SECJ2203 - Software Engineering
|
| 68 |
+
</option>
|
| 69 |
+
<option value="SECV1223 - Web Programming">SECV1223 - Web Programming</option>
|
| 70 |
+
<option value="SECR2043 - Operating Systems">SECR2043 - Operating Systems
|
| 71 |
+
</option>
|
| 72 |
+
<option value="SECJ2154 - Object Oriented Programming">SECJ2154 - Object
|
| 73 |
+
Oriented
|
| 74 |
+
Programming</option>
|
| 75 |
+
<option value="SECR3032 - Computer Networks and Security Project I">SECR3032 -
|
| 76 |
+
Computer Networks and Security Project I</option>
|
| 77 |
+
<option value="SECJ3203 - Theory of Computer Science">SECJ3203 - Theory of
|
| 78 |
+
Computer
|
| 79 |
+
Science</option>
|
| 80 |
+
<option value="SECR4118 - Industrial Training">SECR4118 - Industrial Training
|
| 81 |
+
</option>
|
| 82 |
+
<option value="SECR4114 - Industrial Training Report">SECR4114 - Industrial
|
| 83 |
+
Training
|
| 84 |
+
Report</option>
|
| 85 |
+
<option value="SECR4134 - Computer Networks and Security Project II">SECR4134 -
|
| 86 |
+
Computer Networks and Security Project II</option>
|
| 87 |
+
<option value="SCSD3761 - Technopreneurship Seminar">SCSD3761 -
|
| 88 |
+
Technopreneurship
|
| 89 |
+
Seminar</option>
|
| 90 |
+
</optgroup>
|
| 91 |
+
|
| 92 |
+
<optgroup label="ELECTIVES - SECRH">
|
| 93 |
+
<option value="SECR3104 - Applications Development">SECR3104 - Applications
|
| 94 |
+
Development</option>
|
| 95 |
+
<option value="SECJ3553 - Artificial Intelligence">SECJ3553 - Artificial
|
| 96 |
+
Intelligence</option>
|
| 97 |
+
<option value="SECR2242 - Computer Networks">SECR2242 - Computer Networks
|
| 98 |
+
</option>
|
| 99 |
+
<option value="SECR2941 - Computer Networks Lab">SECR2941 - Computer Networks
|
| 100 |
+
Lab
|
| 101 |
+
</option>
|
| 102 |
+
<option value="SECR3241 - Internetworking Technology">SECR3241 - Internetworking
|
| 103 |
+
Technology</option>
|
| 104 |
+
<option value="SECR3941 - Internetworking Technology Lab">SECR3941 -
|
| 105 |
+
Internetworking
|
| 106 |
+
Technology Lab</option>
|
| 107 |
+
<option value="SECR3413 - Computer Security">SECR3413 - Computer Security
|
| 108 |
+
</option>
|
| 109 |
+
<option value="SECR3443 - Cryptography">SECR3443 - Cryptography</option>
|
| 110 |
+
<option value="SECR3223 - High Performance & Parallel Computing">SECR3223 - High
|
| 111 |
+
Performance & Parallel Computing</option>
|
| 112 |
+
<option value="SECR3253 - Network Programming">SECR3253 - Network Programming
|
| 113 |
+
</option>
|
| 114 |
+
<option value="SECR3263 - Wireless Sensor Network">SECR3263 - Wireless Sensor
|
| 115 |
+
Network</option>
|
| 116 |
+
<option value="SECR4453 - Network Security">SECR4453 - Network Security</option>
|
| 117 |
+
<option value="SECR4483 - Secure Programming">SECR4483 - Secure Programming
|
| 118 |
+
</option>
|
| 119 |
+
<option value="SECR4973 - Special Topics on Computer Network & Security">
|
| 120 |
+
SECR4973 -
|
| 121 |
+
Special Topics on Computer Network & Security</option>
|
| 122 |
+
</optgroup>
|
| 123 |
+
|
| 124 |
+
<optgroup label="ELECTIVES - PRISMS">
|
| 125 |
+
<option value="SECR5013 - Cryptographic Engineering">SECR5013 - Cryptographic
|
| 126 |
+
Engineering</option>
|
| 127 |
+
<option value="SECR5023 - Digital Forensics">SECR5023 - Digital Forensics
|
| 128 |
+
</option>
|
| 129 |
+
<option value="SECR5033 - Information Security Governance and Risk Management">
|
| 130 |
+
SECR5033 - Information Security Governance and Risk Management</option>
|
| 131 |
+
<option value="SECR5043 - Cloud Computing Security">SECR5043 - Cloud Computing
|
| 132 |
+
Security</option>
|
| 133 |
+
<option value="SECR5053 - Penetration Testing">SECR5053 - Penetration Testing
|
| 134 |
+
</option>
|
| 135 |
+
<option value="SECJ5013 - Secure Software Engineering">SECJ5013 - Secure
|
| 136 |
+
Software
|
| 137 |
+
Engineering</option>
|
| 138 |
+
<option value="SECJ5023 - Advanced Theory of Computer Science">SECJ5023 -
|
| 139 |
+
Advanced
|
| 140 |
+
Theory of Computer Science</option>
|
| 141 |
+
<option value="SECJ5033 - Advanced Data Structure and Algorithms">SECJ5033 -
|
| 142 |
+
Advanced Data Structure and Algorithms</option>
|
| 143 |
+
<option value="SECJ5043 - Advanced Artificial Intelligence">SECJ5043 - Advanced
|
| 144 |
+
Artificial Intelligence</option>
|
| 145 |
+
<option value="SECP5013 - Advanced Analytics for Data Science">SECP5013 -
|
| 146 |
+
Advanced
|
| 147 |
+
Analytics for Data Science</option>
|
| 148 |
+
<option value="SECP5023 - Big Data Management">SECP5023 - Big Data Management
|
| 149 |
+
</option>
|
| 150 |
+
<option value="SECP5033 - Business Intelligence and Analytics">SECP5033 -
|
| 151 |
+
Business
|
| 152 |
+
Intelligence and Analytics</option>
|
| 153 |
+
<option value="SECP5043 - Data Science Governance">SECP5043 - Data Science
|
| 154 |
+
Governance</option>
|
| 155 |
+
<option value="SECP5053 - Massive Mining and Streaming">SECP5053 - Massive
|
| 156 |
+
Mining
|
| 157 |
+
and Streaming</option>
|
| 158 |
+
<option value="SECP5063 - Statistics for Data Science">SECP5063 - Statistics for
|
| 159 |
+
Data Science</option>
|
| 160 |
+
</optgroup>
|
| 161 |
+
|
| 162 |
+
<optgroup label="UNIVERSITY GENERAL COURSES">
|
| 163 |
+
<option value="UHIS1022 - Philosophy and Current Issues">UHIS1022 - Philosophy
|
| 164 |
+
and
|
| 165 |
+
Current Issues</option>
|
| 166 |
+
<option value="UHMS1182 - Appreciation of Ethics and Civilisation">UHMS1182 -
|
| 167 |
+
Appreciation of Ethics and Civilisation</option>
|
| 168 |
+
<option value="UHLM1012 - Malaysia Language for Communication">UHLM1012 -
|
| 169 |
+
Malaysia
|
| 170 |
+
Language for Communication</option>
|
| 171 |
+
<option value="ULRS3032 - Entrepreneurship and Innovation">ULRS3032 -
|
| 172 |
+
Entrepreneurship and Innovation</option>
|
| 173 |
+
<option value="ULRS1012 - Value and Identity">ULRS1012 - Value and Identity
|
| 174 |
+
</option>
|
| 175 |
+
<option value="UKQF2xx2 - Service Learning Co-curriculum Elective">UKQF2xx2 -
|
| 176 |
+
Service Learning Co-curriculum Elective</option>
|
| 177 |
+
<option value="UHLB2122 - Academic Communication Skills">UHLB2122 - Academic
|
| 178 |
+
Communication Skills</option>
|
| 179 |
+
<option value="UHLB3132 - Professional Communication Skills">UHLB3132 -
|
| 180 |
+
Professional
|
| 181 |
+
Communication Skills</option>
|
| 182 |
+
<option value="UHLx1112 - Foreign Language Elective">UHLx1112 - Foreign Language
|
| 183 |
+
Elective</option>
|
| 184 |
+
</optgroup>
|
| 185 |
+
</select>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div class="form-group">
|
| 189 |
+
<label class="form-label">Lecturer Name</label>
|
| 190 |
+
<input type="text" id="lecturerName" class="form-input" placeholder="Your name">
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div class="form-group">
|
| 194 |
+
<label class="form-label">Classroom</label>
|
| 195 |
+
<select id="classroomSelect" class="form-select">
|
| 196 |
+
<option value="">Loading classrooms...</option>
|
| 197 |
+
</select>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<button id="createSessionBtn" class="btn btn-primary">Create Session</button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<!-- Active Sessions List -->
|
| 204 |
+
<div class="glass-card">
|
| 205 |
+
<h3>Active Sessions</h3>
|
| 206 |
+
<div id="sessionsList" class="mt-2">
|
| 207 |
+
<p class="text-muted">No active sessions</p>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<!-- Right Column: Current Session Display -->
|
| 213 |
+
<div>
|
| 214 |
+
<!-- Attendance Code Display -->
|
| 215 |
+
<div id="codeDisplayCard" class="glass-card mb-2 hidden">
|
| 216 |
+
<h2 class="text-center">Attendance Code</h2>
|
| 217 |
+
<div class="code-display" id="attendanceCodeDisplay">------</div>
|
| 218 |
+
<div class="text-center">
|
| 219 |
+
<p class="text-muted" id="codeTimer">Refreshes in: --s</p>
|
| 220 |
+
<p class="text-muted" style="font-size: 0.875rem;">Code refreshes every 10 seconds</p>
|
| 221 |
+
</div>
|
| 222 |
+
<button id="endSessionBtn" class="btn btn-outline mt-2" style="width: 100%;">End
|
| 223 |
+
Session</button>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<!-- Attendance Statistics -->
|
| 227 |
+
<div id="statsCard" class="glass-card mb-2 hidden">
|
| 228 |
+
<h3>Attendance Statistics</h3>
|
| 229 |
+
<div class="grid grid-2 gap-2 mt-2">
|
| 230 |
+
<div class="text-center">
|
| 231 |
+
<div style="font-size: 2.5rem; font-weight: 700; color: var(--color-success);"
|
| 232 |
+
id="presentCount">0</div>
|
| 233 |
+
<p class="text-muted">Present</p>
|
| 234 |
+
</div>
|
| 235 |
+
<div class="text-center">
|
| 236 |
+
<div style="font-size: 2.5rem; font-weight: 700; color: var(--color-primary);"
|
| 237 |
+
id="sessionDuration">0m</div>
|
| 238 |
+
<p class="text-muted">Duration</p>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<!-- Real-time Attendance List -->
|
| 244 |
+
<div id="attendanceListCard" class="glass-card hidden">
|
| 245 |
+
<div
|
| 246 |
+
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
| 247 |
+
<h3 style="margin: 0;">Attendance Records</h3>
|
| 248 |
+
<button id="exportBtn" class="btn btn-secondary"
|
| 249 |
+
style="padding: 0.5rem 1rem; font-size: 0.875rem;">📥 Export</button>
|
| 250 |
+
</div>
|
| 251 |
+
<div id="attendanceList" style="max-height: 400px; overflow-y: auto;">
|
| 252 |
+
<p class="text-muted">No attendance records yet</p>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</section>
|
| 259 |
+
|
| 260 |
+
<!-- Loading Overlay -->
|
| 261 |
+
<div id="loadingOverlay" class="loading-overlay hidden">
|
| 262 |
+
<div class="text-center">
|
| 263 |
+
<div class="spinner"></div>
|
| 264 |
+
<p class="mt-2" id="loadingText">Processing...</p>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<!-- Error/Success Messages -->
|
| 269 |
+
<div id="messageDisplay" class="hidden"
|
| 270 |
+
style="position: fixed; top: 100px; right: 20px; z-index: 9999; max-width: 400px;"></div>
|
| 271 |
+
|
| 272 |
+
<script src="{{ url_for('static', filename='js/lecturer.js') }}"></script>
|
| 273 |
+
<script>
|
| 274 |
+
async function logout() {
|
| 275 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 276 |
+
|
| 277 |
+
try {
|
| 278 |
+
const response = await fetch('/api/auth/logout', {
|
| 279 |
+
method: 'POST',
|
| 280 |
+
headers: { 'Content-Type': 'application/json' }
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
const data = await response.json();
|
| 284 |
+
|
| 285 |
+
if (data.success) {
|
| 286 |
+
alert(data.message);
|
| 287 |
+
window.location.href = '/';
|
| 288 |
+
} else {
|
| 289 |
+
alert('Logout failed');
|
| 290 |
+
}
|
| 291 |
+
} catch (error) {
|
| 292 |
+
alert('Logout failed');
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
</script>
|
| 296 |
+
</body>
|
| 297 |
+
|
| 298 |
+
</html>
|
lecturer_history.html
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Session History - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container">
|
| 22 |
+
<h1 class="text-center mb-3">📊 Session History</h1>
|
| 23 |
+
|
| 24 |
+
<!-- Lecturer Info Card -->
|
| 25 |
+
<div class="glass-card mb-2">
|
| 26 |
+
<div class="flex-between">
|
| 27 |
+
<div>
|
| 28 |
+
<h3 id="lecturerName">Loading...</h3>
|
| 29 |
+
<p class="text-muted" id="lecturerId"></p>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="text-right">
|
| 32 |
+
<div style="font-size: 3rem; color: var(--color-primary);" id="totalSessions">0</div>
|
| 33 |
+
<p class="text-muted">Total Sessions</p>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- Session Records -->
|
| 39 |
+
<div id="historyContainer">
|
| 40 |
+
<div class="text-center mt-3">
|
| 41 |
+
<div class="spinner"></div>
|
| 42 |
+
<p class="text-muted mt-2">Loading session history...</p>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<!-- Empty State -->
|
| 47 |
+
<div id="emptyState" class="glass-card text-center hidden" style="padding: 3rem;">
|
| 48 |
+
<div style="font-size: 4rem; margin-bottom: 1rem;">📭</div>
|
| 49 |
+
<h2>No Sessions Yet</h2>
|
| 50 |
+
<p class="text-muted mt-2">You haven't created any attendance sessions yet. Go to the dashboard to
|
| 51 |
+
create your first session!</p>
|
| 52 |
+
<a href="/lecturer" class="btn btn-primary mt-2">Create Session</a>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</section>
|
| 56 |
+
|
| 57 |
+
<script>
|
| 58 |
+
// Load session history on page load
|
| 59 |
+
document.addEventListener('DOMContentLoaded', loadHistory);
|
| 60 |
+
|
| 61 |
+
async function loadHistory() {
|
| 62 |
+
try {
|
| 63 |
+
const response = await fetch('/api/lecturer/history');
|
| 64 |
+
const data = await response.json();
|
| 65 |
+
|
| 66 |
+
if (data.success) {
|
| 67 |
+
displayHistory(data.data);
|
| 68 |
+
} else {
|
| 69 |
+
showError(data.error);
|
| 70 |
+
}
|
| 71 |
+
} catch (error) {
|
| 72 |
+
showError('Failed to load session history');
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function displayHistory(data) {
|
| 77 |
+
// Update lecturer info
|
| 78 |
+
document.getElementById('lecturerName').textContent = data.lecturer.name;
|
| 79 |
+
document.getElementById('lecturerId').textContent = `Matric: ${data.lecturer.matric_no}`;
|
| 80 |
+
document.getElementById('totalSessions').textContent = data.total_sessions;
|
| 81 |
+
|
| 82 |
+
const container = document.getElementById('historyContainer');
|
| 83 |
+
|
| 84 |
+
if (data.history.length === 0) {
|
| 85 |
+
container.innerHTML = '';
|
| 86 |
+
document.getElementById('emptyState').classList.remove('hidden');
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Store history data globally for export function
|
| 91 |
+
window.sessionHistory = data.history;
|
| 92 |
+
|
| 93 |
+
// Build history cards
|
| 94 |
+
let html = '<div class="grid grid-1 gap-2">';
|
| 95 |
+
|
| 96 |
+
data.history.forEach(session => {
|
| 97 |
+
const startDate = new Date(session.start_time);
|
| 98 |
+
const endDate = session.end_time ? new Date(session.end_time) : null;
|
| 99 |
+
const statusBadge = session.is_active
|
| 100 |
+
? '<span class="badge badge-success">🟢 Active</span>'
|
| 101 |
+
: '<span class="badge" style="background: rgba(100,100,100,0.2);">⚫ Ended</span>';
|
| 102 |
+
|
| 103 |
+
html += `
|
| 104 |
+
<div class="glass-card">
|
| 105 |
+
<div class="flex-between mb-1">
|
| 106 |
+
<div>
|
| 107 |
+
<h3>${session.course_name}</h3>
|
| 108 |
+
<p class="text-muted">${session.classroom} - ${session.building}</p>
|
| 109 |
+
</div>
|
| 110 |
+
${statusBadge}
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="grid grid-3 gap-1 mt-2" style="font-size: 0.9rem;">
|
| 114 |
+
<div>
|
| 115 |
+
<strong>📅 Started:</strong><br>
|
| 116 |
+
${startDate.toLocaleDateString()} ${startDate.toLocaleTimeString()}
|
| 117 |
+
</div>
|
| 118 |
+
<div>
|
| 119 |
+
<strong>⏱️ Duration:</strong><br>
|
| 120 |
+
${session.duration_minutes} minutes
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<strong>👥 Attendance:</strong><br>
|
| 124 |
+
${session.present_count} / ${session.total_attendance} students
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
${session.attendance_records.length > 0 ? `
|
| 129 |
+
<div class="mt-2">
|
| 130 |
+
<div class="grid grid-2 gap-1">
|
| 131 |
+
<button class="btn btn-outline" onclick="toggleDetails(${session.id})">
|
| 132 |
+
📋 View Details (${session.attendance_records.length})
|
| 133 |
+
</button>
|
| 134 |
+
<button class="btn btn-secondary" onclick="exportSession(${session.id})">
|
| 135 |
+
📥 Export CSV
|
| 136 |
+
</button>
|
| 137 |
+
</div>
|
| 138 |
+
<div id="details-${session.id}" class="hidden mt-2" style="max-height: 300px; overflow-y: auto;">
|
| 139 |
+
<table style="width: 100%; font-size: 0.875rem;">
|
| 140 |
+
<thead>
|
| 141 |
+
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
| 142 |
+
<th style="padding: 0.5rem; text-align: left;">Student ID</th>
|
| 143 |
+
<th style="padding: 0.5rem; text-align: left;">Name</th>
|
| 144 |
+
<th style="padding: 0.5rem; text-align: center;">Time</th>
|
| 145 |
+
<th style="padding: 0.5rem; text-align: center;">Verification</th>
|
| 146 |
+
</tr>
|
| 147 |
+
</thead>
|
| 148 |
+
<tbody>
|
| 149 |
+
${session.attendance_records.map(record => {
|
| 150 |
+
const markedTime = new Date(record.marked_at);
|
| 151 |
+
return `
|
| 152 |
+
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
| 153 |
+
<td style="padding: 0.5rem;">${record.student_id}</td>
|
| 154 |
+
<td style="padding: 0.5rem;">${record.student_name}</td>
|
| 155 |
+
<td style="padding: 0.5rem; text-align: center;">${markedTime.toLocaleTimeString()}</td>
|
| 156 |
+
<td style="padding: 0.5rem; text-align: center;">
|
| 157 |
+
<span class="badge ${record.face_verified ? 'badge-success' : 'badge-error'}" style="font-size: 0.7rem;">Face</span>
|
| 158 |
+
<span class="badge ${record.location_verified ? 'badge-success' : 'badge-error'}" style="font-size: 0.7rem;">GPS</span>
|
| 159 |
+
<span class="badge ${record.code_verified ? 'badge-success' : 'badge-error'}" style="font-size: 0.7rem;">Code</span>
|
| 160 |
+
</td>
|
| 161 |
+
</tr>
|
| 162 |
+
`;
|
| 163 |
+
}).join('')}
|
| 164 |
+
</tbody>
|
| 165 |
+
</table>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
` : '<p class="text-muted mt-2">No attendance records</p>'}
|
| 169 |
+
</div>
|
| 170 |
+
`;
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
html += '</div>';
|
| 174 |
+
container.innerHTML = html;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function toggleDetails(sessionId) {
|
| 178 |
+
const details = document.getElementById(`details-${sessionId}`);
|
| 179 |
+
details.classList.toggle('hidden');
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function exportSession(sessionId) {
|
| 183 |
+
const session = window.sessionHistory.find(s => s.id === sessionId);
|
| 184 |
+
if (!session) return;
|
| 185 |
+
|
| 186 |
+
const rows = [
|
| 187 |
+
['Student ID', 'Name', 'Time', 'Face Verified', 'Location Verified', 'Code Verified']
|
| 188 |
+
];
|
| 189 |
+
|
| 190 |
+
session.attendance_records.forEach(record => {
|
| 191 |
+
const dateObj = new Date(record.marked_at);
|
| 192 |
+
const time = `${dateObj.toLocaleDateString()} ${dateObj.toLocaleTimeString()}`;
|
| 193 |
+
rows.push([
|
| 194 |
+
record.student_id,
|
| 195 |
+
record.student_name,
|
| 196 |
+
time,
|
| 197 |
+
record.face_verified ? 'Yes' : 'No',
|
| 198 |
+
record.location_verified ? 'Yes' : 'No',
|
| 199 |
+
record.code_verified ? 'Yes' : 'No'
|
| 200 |
+
]);
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
let csvContent = "data:text/csv;charset=utf-8,"
|
| 204 |
+
+ rows.map(e => e.map(cell => `"${cell}"`).join(",")).join("\n");
|
| 205 |
+
|
| 206 |
+
const encodedUri = encodeURI(csvContent);
|
| 207 |
+
const link = document.createElement("a");
|
| 208 |
+
link.setAttribute("href", encodedUri);
|
| 209 |
+
link.setAttribute("download", `attendance_${session.course_name}_${new Date().toISOString().slice(0, 10)}.csv`);
|
| 210 |
+
document.body.appendChild(link);
|
| 211 |
+
link.click();
|
| 212 |
+
document.body.removeChild(link);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function showError(message) {
|
| 216 |
+
const container = document.getElementById('historyContainer');
|
| 217 |
+
container.innerHTML = `
|
| 218 |
+
<div class="alert alert-error">
|
| 219 |
+
${message}
|
| 220 |
+
</div>
|
| 221 |
+
`;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
async function logout() {
|
| 225 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 226 |
+
|
| 227 |
+
try {
|
| 228 |
+
const response = await fetch('/api/auth/logout', {
|
| 229 |
+
method: 'POST',
|
| 230 |
+
headers: { 'Content-Type': 'application/json' }
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
const data = await response.json();
|
| 234 |
+
|
| 235 |
+
if (data.success) {
|
| 236 |
+
alert(data.message);
|
| 237 |
+
window.location.href = '/';
|
| 238 |
+
} else {
|
| 239 |
+
alert('Logout failed');
|
| 240 |
+
}
|
| 241 |
+
} catch (error) {
|
| 242 |
+
alert('Logout failed');
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
</script>
|
| 246 |
+
</body>
|
| 247 |
+
|
| 248 |
+
</html>
|
login.html
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Login - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container" style="max-width: 500px;">
|
| 22 |
+
<h1 class="text-center mb-3">Login to Attendr</h1>
|
| 23 |
+
<p class="text-center text-muted mb-3">
|
| 24 |
+
Access your student or lecturer account
|
| 25 |
+
</p>
|
| 26 |
+
|
| 27 |
+
<!-- Login Form -->
|
| 28 |
+
<div class="glass-card">
|
| 29 |
+
<div class="form-group">
|
| 30 |
+
<label class="form-label">Matric Number</label>
|
| 31 |
+
<input type="text" id="matricNo" class="form-input" placeholder="e.g., A20EC0001 or L001"
|
| 32 |
+
style="text-transform: uppercase;">
|
| 33 |
+
<small class="text-muted" style="font-size: 0.875rem;">
|
| 34 |
+
Students: Start with A or B | Lecturers: Start with L
|
| 35 |
+
</small>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="form-group">
|
| 39 |
+
<label class="form-label">Password</label>
|
| 40 |
+
<input type="password" id="password" class="form-input" placeholder="Enter your password">
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<!-- Status Messages -->
|
| 44 |
+
<div id="statusMessage" class="mt-2"></div>
|
| 45 |
+
|
| 46 |
+
<!-- Login Button -->
|
| 47 |
+
<button id="loginBtn" class="btn btn-primary mt-2" style="width: 100%;">
|
| 48 |
+
🔐 Login
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<div class="text-center mt-2">
|
| 52 |
+
<p class="text-muted">
|
| 53 |
+
Don't have an account? <a href="/register" style="color: var(--color-primary);">Register
|
| 54 |
+
here</a>
|
| 55 |
+
</p>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</section>
|
| 60 |
+
|
| 61 |
+
<!-- Loading Overlay -->
|
| 62 |
+
<div id="loadingOverlay" class="loading-overlay hidden">
|
| 63 |
+
<div class="text-center">
|
| 64 |
+
<div class="spinner"></div>
|
| 65 |
+
<p class="mt-2" id="loadingText">Logging in...</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<script>
|
| 70 |
+
// Handle Enter key
|
| 71 |
+
document.getElementById('password').addEventListener('keypress', (e) => {
|
| 72 |
+
if (e.key === 'Enter') {
|
| 73 |
+
document.getElementById('loginBtn').click();
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
// Login function
|
| 78 |
+
document.getElementById('loginBtn').addEventListener('click', async () => {
|
| 79 |
+
const matricNo = document.getElementById('matricNo').value.trim().toUpperCase();
|
| 80 |
+
const password = document.getElementById('password').value;
|
| 81 |
+
|
| 82 |
+
if (!matricNo || !password) {
|
| 83 |
+
showStatus('Please fill in all fields', 'error');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
showLoading('Logging in...');
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
const response = await fetch('/api/auth/login', {
|
| 91 |
+
method: 'POST',
|
| 92 |
+
headers: { 'Content-Type': 'application/json' },
|
| 93 |
+
body: JSON.stringify({
|
| 94 |
+
matric_no: matricNo,
|
| 95 |
+
password: password
|
| 96 |
+
})
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
const data = await response.json();
|
| 100 |
+
hideLoading();
|
| 101 |
+
|
| 102 |
+
if (data.success) {
|
| 103 |
+
showStatus(data.message, 'success');
|
| 104 |
+
|
| 105 |
+
// Redirect after 1 second
|
| 106 |
+
setTimeout(() => {
|
| 107 |
+
window.location.href = data.data.redirect;
|
| 108 |
+
}, 1000);
|
| 109 |
+
} else {
|
| 110 |
+
showStatus(data.error, 'error');
|
| 111 |
+
}
|
| 112 |
+
} catch (error) {
|
| 113 |
+
hideLoading();
|
| 114 |
+
showStatus('Login failed. Please try again.', 'error');
|
| 115 |
+
}
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
function showLoading(text) {
|
| 119 |
+
document.getElementById('loadingText').textContent = text;
|
| 120 |
+
document.getElementById('loadingOverlay').classList.remove('hidden');
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function hideLoading() {
|
| 124 |
+
document.getElementById('loadingOverlay').classList.add('hidden');
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function showStatus(message, type) {
|
| 128 |
+
const statusDiv = document.getElementById('statusMessage');
|
| 129 |
+
const alertClass = type === 'success' ? 'alert-success' : 'alert-error';
|
| 130 |
+
statusDiv.innerHTML = `<div class="alert ${alertClass}">${message}</div>`;
|
| 131 |
+
|
| 132 |
+
if (type === 'error') {
|
| 133 |
+
setTimeout(() => {
|
| 134 |
+
statusDiv.innerHTML = '';
|
| 135 |
+
}, 5000);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
</script>
|
| 139 |
+
</body>
|
| 140 |
+
|
| 141 |
+
</html>
|
models.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import json
|
| 4 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 5 |
+
|
| 6 |
+
db = SQLAlchemy()
|
| 7 |
+
|
| 8 |
+
class User(db.Model):
|
| 9 |
+
"""User model for authentication - supports both students and lecturers"""
|
| 10 |
+
__tablename__ = 'users'
|
| 11 |
+
|
| 12 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 13 |
+
matric_no = db.Column(db.String(50), unique=True, nullable=False) # A/B for students, L for lecturers
|
| 14 |
+
name = db.Column(db.String(100), nullable=False)
|
| 15 |
+
email = db.Column(db.String(100), unique=True)
|
| 16 |
+
password_hash = db.Column(db.String(255), nullable=False)
|
| 17 |
+
role = db.Column(db.String(20), nullable=False) # 'student' or 'lecturer'
|
| 18 |
+
is_active = db.Column(db.Boolean, default=True)
|
| 19 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 20 |
+
|
| 21 |
+
def set_password(self, password):
|
| 22 |
+
"""Hash and set password"""
|
| 23 |
+
self.password_hash = generate_password_hash(password)
|
| 24 |
+
|
| 25 |
+
def check_password(self, password):
|
| 26 |
+
"""Verify password"""
|
| 27 |
+
return check_password_hash(self.password_hash, password)
|
| 28 |
+
|
| 29 |
+
@staticmethod
|
| 30 |
+
def validate_matric_no(matric_no, role):
|
| 31 |
+
"""Validate matric number format based on role"""
|
| 32 |
+
if not matric_no:
|
| 33 |
+
return False, "Matric number is required"
|
| 34 |
+
|
| 35 |
+
matric_no = matric_no.upper().strip()
|
| 36 |
+
|
| 37 |
+
if role == 'student':
|
| 38 |
+
# Students must start with A or B
|
| 39 |
+
if not (matric_no.startswith('A') or matric_no.startswith('B')):
|
| 40 |
+
return False, "Student matric number must start with 'A' or 'B'"
|
| 41 |
+
elif role == 'lecturer':
|
| 42 |
+
# Lecturers must start with L
|
| 43 |
+
if not matric_no.startswith('L'):
|
| 44 |
+
return False, "Lecturer matric number must start with 'L'"
|
| 45 |
+
else:
|
| 46 |
+
return False, "Invalid role"
|
| 47 |
+
|
| 48 |
+
return True, None
|
| 49 |
+
|
| 50 |
+
def to_dict(self):
|
| 51 |
+
return {
|
| 52 |
+
'id': self.id,
|
| 53 |
+
'matric_no': self.matric_no,
|
| 54 |
+
'name': self.name,
|
| 55 |
+
'email': self.email,
|
| 56 |
+
'role': self.role,
|
| 57 |
+
'is_active': self.is_active,
|
| 58 |
+
'created_at': self.created_at.isoformat() if self.created_at else None
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Student(db.Model):
|
| 63 |
+
"""Student model - stores student information and face encodings"""
|
| 64 |
+
__tablename__ = 'students'
|
| 65 |
+
|
| 66 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 67 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=True) # Link to User
|
| 68 |
+
student_id = db.Column(db.String(50), unique=True, nullable=False)
|
| 69 |
+
name = db.Column(db.String(100), nullable=False)
|
| 70 |
+
email = db.Column(db.String(100), unique=True)
|
| 71 |
+
face_encoding = db.Column(db.Text) # Stored as JSON string
|
| 72 |
+
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 73 |
+
is_active = db.Column(db.Boolean, default=True)
|
| 74 |
+
|
| 75 |
+
# Relationships
|
| 76 |
+
attendance_records = db.relationship('AttendanceRecord', backref='student', lazy=True)
|
| 77 |
+
user = db.relationship('User', backref='student_profile', uselist=False)
|
| 78 |
+
|
| 79 |
+
def get_face_encoding(self):
|
| 80 |
+
"""Deserialize face encoding from JSON"""
|
| 81 |
+
if self.face_encoding:
|
| 82 |
+
return json.loads(self.face_encoding)
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
def set_face_encoding(self, encoding):
|
| 86 |
+
"""Serialize face encoding to JSON"""
|
| 87 |
+
if encoding is not None:
|
| 88 |
+
self.face_encoding = json.dumps(encoding.tolist())
|
| 89 |
+
|
| 90 |
+
def to_dict(self):
|
| 91 |
+
return {
|
| 92 |
+
'id': self.id,
|
| 93 |
+
'student_id': self.student_id,
|
| 94 |
+
'name': self.name,
|
| 95 |
+
'email': self.email,
|
| 96 |
+
'registered_at': self.registered_at.isoformat() if self.registered_at else None,
|
| 97 |
+
'is_active': self.is_active
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class Classroom(db.Model):
|
| 102 |
+
"""Classroom model - stores classroom locations with GPS coordinates"""
|
| 103 |
+
__tablename__ = 'classrooms'
|
| 104 |
+
|
| 105 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 106 |
+
name = db.Column(db.String(100), nullable=False)
|
| 107 |
+
building = db.Column(db.String(100))
|
| 108 |
+
latitude = db.Column(db.Float, nullable=False)
|
| 109 |
+
longitude = db.Column(db.Float, nullable=False)
|
| 110 |
+
radius_meters = db.Column(db.Integer, default=50) # Geofencing radius
|
| 111 |
+
|
| 112 |
+
# Relationships
|
| 113 |
+
sessions = db.relationship('AttendanceSession', backref='classroom', lazy=True)
|
| 114 |
+
|
| 115 |
+
def to_dict(self):
|
| 116 |
+
return {
|
| 117 |
+
'id': self.id,
|
| 118 |
+
'name': self.name,
|
| 119 |
+
'building': self.building,
|
| 120 |
+
'latitude': self.latitude,
|
| 121 |
+
'longitude': self.longitude,
|
| 122 |
+
'radius_meters': self.radius_meters
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class AttendanceSession(db.Model):
|
| 127 |
+
"""Attendance session - represents a class session with auto-refresh codes"""
|
| 128 |
+
__tablename__ = 'attendance_sessions'
|
| 129 |
+
|
| 130 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 131 |
+
course_name = db.Column(db.String(100), nullable=False)
|
| 132 |
+
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
|
| 133 |
+
lecturer_name = db.Column(db.String(100), nullable=False)
|
| 134 |
+
|
| 135 |
+
# Session timing
|
| 136 |
+
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
| 137 |
+
end_time = db.Column(db.DateTime)
|
| 138 |
+
is_active = db.Column(db.Boolean, default=True)
|
| 139 |
+
|
| 140 |
+
# Auto-refresh attendance code
|
| 141 |
+
current_code = db.Column(db.String(10))
|
| 142 |
+
code_generated_at = db.Column(db.DateTime)
|
| 143 |
+
|
| 144 |
+
# Relationships
|
| 145 |
+
attendance_records = db.relationship('AttendanceRecord', backref='session', lazy=True)
|
| 146 |
+
|
| 147 |
+
def to_dict(self):
|
| 148 |
+
return {
|
| 149 |
+
'id': self.id,
|
| 150 |
+
'course_name': self.course_name,
|
| 151 |
+
'classroom_id': self.classroom_id,
|
| 152 |
+
'classroom': self.classroom.to_dict() if self.classroom else None,
|
| 153 |
+
'lecturer_name': self.lecturer_name,
|
| 154 |
+
'start_time': self.start_time.isoformat() if self.start_time else None,
|
| 155 |
+
'end_time': self.end_time.isoformat() if self.end_time else None,
|
| 156 |
+
'is_active': self.is_active,
|
| 157 |
+
'current_code': self.current_code,
|
| 158 |
+
'code_generated_at': self.code_generated_at.isoformat() if self.code_generated_at else None
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class AttendanceRecord(db.Model):
|
| 163 |
+
"""Attendance record - individual student attendance for a session"""
|
| 164 |
+
__tablename__ = 'attendance_records'
|
| 165 |
+
|
| 166 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 167 |
+
session_id = db.Column(db.Integer, db.ForeignKey('attendance_sessions.id'), nullable=False)
|
| 168 |
+
student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False)
|
| 169 |
+
|
| 170 |
+
# Verification details
|
| 171 |
+
marked_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 172 |
+
face_verified = db.Column(db.Boolean, default=False)
|
| 173 |
+
location_verified = db.Column(db.Boolean, default=False)
|
| 174 |
+
code_verified = db.Column(db.Boolean, default=False)
|
| 175 |
+
|
| 176 |
+
# Location data
|
| 177 |
+
student_latitude = db.Column(db.Float)
|
| 178 |
+
student_longitude = db.Column(db.Float)
|
| 179 |
+
distance_from_classroom = db.Column(db.Float) # In meters
|
| 180 |
+
|
| 181 |
+
# Status
|
| 182 |
+
status = db.Column(db.String(20), default='present') # present, late, absent
|
| 183 |
+
|
| 184 |
+
def to_dict(self):
|
| 185 |
+
return {
|
| 186 |
+
'id': self.id,
|
| 187 |
+
'session_id': self.session_id,
|
| 188 |
+
'student': self.student.to_dict() if self.student else None,
|
| 189 |
+
'marked_at': self.marked_at.isoformat() if self.marked_at else None,
|
| 190 |
+
'face_verified': self.face_verified,
|
| 191 |
+
'location_verified': self.location_verified,
|
| 192 |
+
'code_verified': self.code_verified,
|
| 193 |
+
'distance_from_classroom': self.distance_from_classroom,
|
| 194 |
+
'status': self.status
|
| 195 |
+
}
|
register.html
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Register - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container" style="max-width: 800px;">
|
| 22 |
+
<h1 class="text-center mb-3">Register Account</h1>
|
| 23 |
+
<p class="text-center text-muted mb-3">
|
| 24 |
+
Create your student or lecturer account
|
| 25 |
+
</p>
|
| 26 |
+
|
| 27 |
+
<!-- Step 1: Account Details -->
|
| 28 |
+
<div id="step1Card" class="glass-card">
|
| 29 |
+
<h2 class="mb-2">Step 1: Account Information</h2>
|
| 30 |
+
|
| 31 |
+
<div class="form-group">
|
| 32 |
+
<label class="form-label">I am a...</label>
|
| 33 |
+
<select id="roleSelect" class="form-select">
|
| 34 |
+
<option value="">Select your role</option>
|
| 35 |
+
<option value="student">Student</option>
|
| 36 |
+
<option value="lecturer">Lecturer</option>
|
| 37 |
+
</select>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="form-group">
|
| 41 |
+
<label class="form-label">Matric Number</label>
|
| 42 |
+
<input type="text" id="matricNo" class="form-input" placeholder="e.g., A20EC0001 or L001"
|
| 43 |
+
style="text-transform: uppercase;">
|
| 44 |
+
<small class="text-muted" id="matricHelp" style="font-size: 0.875rem;">
|
| 45 |
+
Select your role first
|
| 46 |
+
</small>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="form-group">
|
| 50 |
+
<label class="form-label">Full Name</label>
|
| 51 |
+
<input type="text" id="fullName" class="form-input" placeholder="Your full name">
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div class="form-group">
|
| 55 |
+
<label class="form-label">Email (Optional)</label>
|
| 56 |
+
<input type="email" id="email" class="form-input" placeholder="your.email@graduate.utm.my">
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div class="form-group">
|
| 60 |
+
<label class="form-label">Password</label>
|
| 61 |
+
<input type="password" id="password" class="form-input" placeholder="Create a strong password">
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="form-group">
|
| 65 |
+
<label class="form-label">Confirm Password</label>
|
| 66 |
+
<input type="password" id="confirmPassword" class="form-input" placeholder="Re-enter your password">
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<div id="statusMessage" class="mt-2"></div>
|
| 70 |
+
|
| 71 |
+
<button id="nextBtn" class="btn btn-primary mt-2" style="width: 100%;">
|
| 72 |
+
Next: Face Registration →
|
| 73 |
+
</button>
|
| 74 |
+
|
| 75 |
+
<div class="text-center mt-2">
|
| 76 |
+
<p class="text-muted">
|
| 77 |
+
Already have an account? <a href="/login" style="color: var(--color-primary);">Login here</a>
|
| 78 |
+
</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<!-- Step 2: Face Registration (Students Only) -->
|
| 83 |
+
<div id="step2Card" class="glass-card mt-2 hidden">
|
| 84 |
+
<h2 class="mb-2">Step 2: Face Registration</h2>
|
| 85 |
+
<p class="text-muted mb-2">Capture your face for biometric verification</p>
|
| 86 |
+
|
| 87 |
+
<div class="text-center">
|
| 88 |
+
<video id="video" width="400" height="300" autoplay
|
| 89 |
+
style="border-radius: 12px; border: 2px solid var(--color-primary);"></video>
|
| 90 |
+
<canvas id="canvas" width="400" height="300" style="display: none;"></canvas>
|
| 91 |
+
|
| 92 |
+
<div id="preview" class="mt-2 hidden">
|
| 93 |
+
<img id="capturedImage"
|
| 94 |
+
style="max-width: 400px; border-radius: 12px; border: 2px solid var(--color-success);">
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div id="faceStatusMessage" class="mt-2"></div>
|
| 99 |
+
|
| 100 |
+
<div class="flex-center gap-2 mt-2">
|
| 101 |
+
<button id="startCameraBtn" class="btn btn-secondary">
|
| 102 |
+
📷 Start Camera
|
| 103 |
+
</button>
|
| 104 |
+
<button id="captureBtn" class="btn btn-primary hidden">
|
| 105 |
+
📸 Capture Face
|
| 106 |
+
</button>
|
| 107 |
+
<button id="retakeBtn" class="btn btn-outline hidden">
|
| 108 |
+
🔄 Retake
|
| 109 |
+
</button>
|
| 110 |
+
<button id="registerBtn" class="btn btn-success hidden">
|
| 111 |
+
✅ Complete Registration
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div class="text-center mt-2">
|
| 116 |
+
<button id="backBtn" class="btn btn-outline">
|
| 117 |
+
← Back to Account Details
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<!-- Success Message -->
|
| 123 |
+
<div id="successCard" class="glass-card mt-2 hidden"
|
| 124 |
+
style="background: rgba(34, 197, 94, 0.1); border: 2px solid var(--color-success);">
|
| 125 |
+
<div class="text-center">
|
| 126 |
+
<div style="font-size: 4rem; margin-bottom: 1rem;">✅</div>
|
| 127 |
+
<h2 style="color: var(--color-success);">Registration Successful!</h2>
|
| 128 |
+
<p class="text-muted mt-2" id="successMessage">Your account has been created successfully.</p>
|
| 129 |
+
<div class="flex-center gap-2 mt-2">
|
| 130 |
+
<a href="/login" class="btn btn-primary">Go to Login</a>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<!-- Loading Overlay -->
|
| 138 |
+
<div id="loadingOverlay" class="loading-overlay hidden">
|
| 139 |
+
<div class="text-center">
|
| 140 |
+
<div class="spinner"></div>
|
| 141 |
+
<p class="mt-2" id="loadingText">Processing...</p>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<script>
|
| 146 |
+
let videoStream = null;
|
| 147 |
+
let capturedImageData = null;
|
| 148 |
+
let userRole = null;
|
| 149 |
+
|
| 150 |
+
// Update matric number help text based on role
|
| 151 |
+
document.getElementById('roleSelect').addEventListener('change', (e) => {
|
| 152 |
+
userRole = e.target.value;
|
| 153 |
+
const helpText = document.getElementById('matricHelp');
|
| 154 |
+
|
| 155 |
+
if (userRole === 'student') {
|
| 156 |
+
helpText.textContent = 'Student matric number must start with A or B (e.g., A20EC0001)';
|
| 157 |
+
helpText.style.color = 'var(--color-primary)';
|
| 158 |
+
} else if (userRole === 'lecturer') {
|
| 159 |
+
helpText.textContent = 'Lecturer matric number must start with L (e.g., L001)';
|
| 160 |
+
helpText.style.color = 'var(--color-primary)';
|
| 161 |
+
} else {
|
| 162 |
+
helpText.textContent = 'Select your role first';
|
| 163 |
+
helpText.style.color = 'var(--color-text-muted)';
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
// Next button - validate and move to face registration
|
| 168 |
+
document.getElementById('nextBtn').addEventListener('click', async () => {
|
| 169 |
+
const role = document.getElementById('roleSelect').value;
|
| 170 |
+
const matricNo = document.getElementById('matricNo').value.trim().toUpperCase();
|
| 171 |
+
const fullName = document.getElementById('fullName').value.trim();
|
| 172 |
+
const password = document.getElementById('password').value;
|
| 173 |
+
const confirmPassword = document.getElementById('confirmPassword').value;
|
| 174 |
+
|
| 175 |
+
// Validation
|
| 176 |
+
if (!role) {
|
| 177 |
+
showStatus('Please select your role', 'error');
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (!matricNo || !fullName || !password) {
|
| 182 |
+
showStatus('Please fill in all required fields', 'error');
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if (password !== confirmPassword) {
|
| 187 |
+
showStatus('Passwords do not match', 'error');
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (password.length < 6) {
|
| 192 |
+
showStatus('Password must be at least 6 characters long', 'error');
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// If lecturer, register directly (no face needed)
|
| 197 |
+
if (role === 'lecturer') {
|
| 198 |
+
await registerUser(false);
|
| 199 |
+
} else {
|
| 200 |
+
// Student - go to face registration
|
| 201 |
+
document.getElementById('step1Card').classList.add('hidden');
|
| 202 |
+
document.getElementById('step2Card').classList.remove('hidden');
|
| 203 |
+
}
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
// Back button
|
| 207 |
+
document.getElementById('backBtn').addEventListener('click', () => {
|
| 208 |
+
stopCamera();
|
| 209 |
+
document.getElementById('step2Card').classList.add('hidden');
|
| 210 |
+
document.getElementById('step1Card').classList.remove('hidden');
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
// Camera controls
|
| 214 |
+
document.getElementById('startCameraBtn').addEventListener('click', startCamera);
|
| 215 |
+
document.getElementById('captureBtn').addEventListener('click', captureFace);
|
| 216 |
+
document.getElementById('retakeBtn').addEventListener('click', retake);
|
| 217 |
+
document.getElementById('registerBtn').addEventListener('click', () => registerUser(true));
|
| 218 |
+
|
| 219 |
+
async function startCamera() {
|
| 220 |
+
try {
|
| 221 |
+
videoStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
| 222 |
+
document.getElementById('video').srcObject = videoStream;
|
| 223 |
+
document.getElementById('startCameraBtn').classList.add('hidden');
|
| 224 |
+
document.getElementById('captureBtn').classList.remove('hidden');
|
| 225 |
+
showFaceStatus('Camera ready! Position your face in the frame', 'success');
|
| 226 |
+
} catch (error) {
|
| 227 |
+
showFaceStatus('Camera access denied. Please allow camera access.', 'error');
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function stopCamera() {
|
| 232 |
+
if (videoStream) {
|
| 233 |
+
videoStream.getTracks().forEach(track => track.stop());
|
| 234 |
+
videoStream = null;
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function captureFace() {
|
| 239 |
+
const video = document.getElementById('video');
|
| 240 |
+
const canvas = document.getElementById('canvas');
|
| 241 |
+
const context = canvas.getContext('2d');
|
| 242 |
+
|
| 243 |
+
context.drawImage(video, 0, 0, 400, 300);
|
| 244 |
+
capturedImageData = canvas.toDataURL('image/jpeg');
|
| 245 |
+
|
| 246 |
+
document.getElementById('capturedImage').src = capturedImageData;
|
| 247 |
+
document.getElementById('preview').classList.remove('hidden');
|
| 248 |
+
document.getElementById('video').style.display = 'none';
|
| 249 |
+
document.getElementById('captureBtn').classList.add('hidden');
|
| 250 |
+
document.getElementById('retakeBtn').classList.remove('hidden');
|
| 251 |
+
document.getElementById('registerBtn').classList.remove('hidden');
|
| 252 |
+
|
| 253 |
+
stopCamera();
|
| 254 |
+
showFaceStatus('Face captured! Click "Complete Registration" to finish', 'success');
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function retake() {
|
| 258 |
+
document.getElementById('preview').classList.add('hidden');
|
| 259 |
+
document.getElementById('video').style.display = 'block';
|
| 260 |
+
document.getElementById('retakeBtn').classList.add('hidden');
|
| 261 |
+
document.getElementById('registerBtn').classList.add('hidden');
|
| 262 |
+
capturedImageData = null;
|
| 263 |
+
startCamera();
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
async function registerUser(includeFace) {
|
| 267 |
+
const role = document.getElementById('roleSelect').value;
|
| 268 |
+
const matricNo = document.getElementById('matricNo').value.trim().toUpperCase();
|
| 269 |
+
const fullName = document.getElementById('fullName').value.trim();
|
| 270 |
+
const email = document.getElementById('email').value.trim();
|
| 271 |
+
const password = document.getElementById('password').value;
|
| 272 |
+
|
| 273 |
+
if (includeFace && !capturedImageData) {
|
| 274 |
+
showFaceStatus('Please capture your face first', 'error');
|
| 275 |
+
return;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
showLoading('Creating your account...');
|
| 279 |
+
|
| 280 |
+
try {
|
| 281 |
+
// Step 1: Create user account
|
| 282 |
+
const userResponse = await fetch('/api/auth/register', {
|
| 283 |
+
method: 'POST',
|
| 284 |
+
headers: { 'Content-Type': 'application/json' },
|
| 285 |
+
body: JSON.stringify({
|
| 286 |
+
role: role,
|
| 287 |
+
matric_no: matricNo,
|
| 288 |
+
name: fullName,
|
| 289 |
+
email: email || null,
|
| 290 |
+
password: password
|
| 291 |
+
})
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
const userData = await userResponse.json();
|
| 295 |
+
|
| 296 |
+
if (!userData.success) {
|
| 297 |
+
hideLoading();
|
| 298 |
+
showStatus(userData.error, 'error');
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Step 2: Register face if student
|
| 303 |
+
if (includeFace) {
|
| 304 |
+
const faceResponse = await fetch('/api/register_face', {
|
| 305 |
+
method: 'POST',
|
| 306 |
+
headers: { 'Content-Type': 'application/json' },
|
| 307 |
+
body: JSON.stringify({
|
| 308 |
+
student_id: matricNo,
|
| 309 |
+
name: fullName,
|
| 310 |
+
email: email || null,
|
| 311 |
+
image: capturedImageData
|
| 312 |
+
})
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
const faceData = await faceResponse.json();
|
| 316 |
+
hideLoading();
|
| 317 |
+
|
| 318 |
+
if (!faceData.success) {
|
| 319 |
+
showFaceStatus(faceData.error, 'error');
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
hideLoading();
|
| 325 |
+
|
| 326 |
+
// Show success
|
| 327 |
+
document.getElementById('step1Card').classList.add('hidden');
|
| 328 |
+
document.getElementById('step2Card').classList.add('hidden');
|
| 329 |
+
document.getElementById('successCard').classList.remove('hidden');
|
| 330 |
+
document.getElementById('successMessage').textContent =
|
| 331 |
+
`Your ${role} account has been created successfully${includeFace ? ' with face registration' : ''}. You can now login to access the system.`;
|
| 332 |
+
|
| 333 |
+
} catch (error) {
|
| 334 |
+
hideLoading();
|
| 335 |
+
showStatus('Registration failed. Please try again.', 'error');
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function showLoading(text) {
|
| 340 |
+
document.getElementById('loadingText').textContent = text;
|
| 341 |
+
document.getElementById('loadingOverlay').classList.remove('hidden');
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function hideLoading() {
|
| 345 |
+
document.getElementById('loadingOverlay').classList.add('hidden');
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function showStatus(message, type) {
|
| 349 |
+
const statusDiv = document.getElementById('statusMessage');
|
| 350 |
+
const alertClass = type === 'success' ? 'alert-success' : 'alert-error';
|
| 351 |
+
statusDiv.innerHTML = `<div class="alert ${alertClass}">${message}</div>`;
|
| 352 |
+
|
| 353 |
+
setTimeout(() => {
|
| 354 |
+
statusDiv.innerHTML = '';
|
| 355 |
+
}, 5000);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function showFaceStatus(message, type) {
|
| 359 |
+
const statusDiv = document.getElementById('faceStatusMessage');
|
| 360 |
+
const alertClass = type === 'success' ? 'alert-success' : 'alert-error';
|
| 361 |
+
statusDiv.innerHTML = `<div class="alert ${alertClass}">${message}</div>`;
|
| 362 |
+
}
|
| 363 |
+
</script>
|
| 364 |
+
</body>
|
| 365 |
+
|
| 366 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.0.0
|
| 2 |
+
Flask-SQLAlchemy==3.1.1
|
| 3 |
+
opencv-python==4.8.1.78
|
| 4 |
+
face-recognition==1.3.0
|
| 5 |
+
numpy==1.24.3
|
| 6 |
+
Pillow==10.1.0
|
| 7 |
+
python-dotenv==1.0.0
|
| 8 |
+
werkzeug==3.0.1
|
student.html
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Student Portal - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container" style="max-width: 800px;">
|
| 22 |
+
<h1 class="text-center mb-3">Student Attendance Portal</h1>
|
| 23 |
+
|
| 24 |
+
<!-- Step 1: Student ID Input -->
|
| 25 |
+
<div id="step1" class="glass-card mb-2">
|
| 26 |
+
<h2>Step 1: Enter Student ID</h2>
|
| 27 |
+
<div class="form-group">
|
| 28 |
+
<label class="form-label">Student ID</label>
|
| 29 |
+
<input type="text" id="studentId" class="form-input" placeholder="e.g., A20EC0001">
|
| 30 |
+
</div>
|
| 31 |
+
<div class="form-group">
|
| 32 |
+
<label class="form-label">Select Session</label>
|
| 33 |
+
<select id="sessionSelect" class="form-select">
|
| 34 |
+
<option value="">Loading sessions...</option>
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
<button id="startBtn" class="btn btn-primary" disabled>Start Verification</button>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- Step 2: Device Permissions -->
|
| 41 |
+
<div id="step2" class="glass-card mb-2 hidden">
|
| 42 |
+
<h2>Step 2: Enable Permissions</h2>
|
| 43 |
+
<p class="text-muted">We need access to your camera and location to verify your attendance.</p>
|
| 44 |
+
|
| 45 |
+
<div class="grid grid-2 gap-2 mt-2">
|
| 46 |
+
<div class="status-badge status-info" id="cameraStatus">
|
| 47 |
+
<span>📷</span>
|
| 48 |
+
<span>Camera: Waiting...</span>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="status-badge status-info" id="gpsStatus">
|
| 51 |
+
<span>📍</span>
|
| 52 |
+
<span>GPS: Waiting...</span>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<button id="enablePermissionsBtn" class="btn btn-primary mt-2">Enable Camera & GPS</button>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- Step 3: Face Verification -->
|
| 60 |
+
<div id="step3" class="glass-card mb-2 hidden">
|
| 61 |
+
<h2>Step 3: Face Verification</h2>
|
| 62 |
+
<p class="text-muted">Position your face in the frame for verification.</p>
|
| 63 |
+
|
| 64 |
+
<div class="video-container mt-2">
|
| 65 |
+
<video id="videoPreview" class="video-preview" autoplay playsinline></video>
|
| 66 |
+
<canvas id="canvas" style="display: none;"></canvas>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<div id="faceStatus" class="mt-2"></div>
|
| 70 |
+
|
| 71 |
+
<button id="verifyFaceBtn" class="btn btn-primary mt-2">Verify My Face</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<!-- Step 4: Location Verification -->
|
| 75 |
+
<div id="step4" class="glass-card mb-2 hidden">
|
| 76 |
+
<h2>Step 4: Location Verification</h2>
|
| 77 |
+
<p class="text-muted">Verifying you are in the classroom...</p>
|
| 78 |
+
|
| 79 |
+
<div id="locationStatus" class="mt-2"></div>
|
| 80 |
+
|
| 81 |
+
<button id="verifyLocationBtn" class="btn btn-primary mt-2">Verify Location</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- Step 5: Attendance Code -->
|
| 85 |
+
<div id="step5" class="glass-card mb-2 hidden">
|
| 86 |
+
<h2>Step 5: Enter Attendance Code</h2>
|
| 87 |
+
<p class="text-muted">Enter the code displayed by your lecturer.</p>
|
| 88 |
+
|
| 89 |
+
<div class="form-group mt-2">
|
| 90 |
+
<label class="form-label">Attendance Code</label>
|
| 91 |
+
<input type="text" id="attendanceCode" class="form-input" placeholder="Enter 6-digit code"
|
| 92 |
+
maxlength="6"
|
| 93 |
+
style="text-transform: uppercase; font-size: 1.5rem; text-align: center; letter-spacing: 0.5rem;">
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<button id="submitAttendanceBtn" class="btn btn-success mt-2">Mark Attendance</button>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<!-- Success Message -->
|
| 100 |
+
<div id="successMessage" class="glass-card hidden"
|
| 101 |
+
style="background: rgba(34, 197, 94, 0.1); border: 2px solid var(--color-success);">
|
| 102 |
+
<div class="text-center">
|
| 103 |
+
<div style="font-size: 4rem; margin-bottom: 1rem;">✅</div>
|
| 104 |
+
<h2 style="color: var(--color-success);">Attendance Marked Successfully!</h2>
|
| 105 |
+
<p id="successDetails" class="text-muted mt-2"></p>
|
| 106 |
+
<button onclick="location.reload()" class="btn btn-primary mt-2">Mark Another Attendance</button>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<!-- Error Display -->
|
| 111 |
+
<div id="errorDisplay" class="hidden"></div>
|
| 112 |
+
</div>
|
| 113 |
+
</section>
|
| 114 |
+
|
| 115 |
+
<!-- Loading Overlay -->
|
| 116 |
+
<div id="loadingOverlay" class="loading-overlay hidden">
|
| 117 |
+
<div class="text-center">
|
| 118 |
+
<div class="spinner"></div>
|
| 119 |
+
<p class="mt-2" id="loadingText">Processing...</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<script src="{{ url_for('static', filename='js/student.js') }}"></script>
|
| 124 |
+
<script>
|
| 125 |
+
async function logout() {
|
| 126 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 127 |
+
|
| 128 |
+
try {
|
| 129 |
+
const response = await fetch('/api/auth/logout', {
|
| 130 |
+
method: 'POST',
|
| 131 |
+
headers: { 'Content-Type': 'application/json' }
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
const data = await response.json();
|
| 135 |
+
|
| 136 |
+
if (data.success) {
|
| 137 |
+
alert(data.message);
|
| 138 |
+
window.location.href = '/';
|
| 139 |
+
} else {
|
| 140 |
+
alert('Logout failed');
|
| 141 |
+
}
|
| 142 |
+
} catch (error) {
|
| 143 |
+
alert('Logout failed');
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
</script>
|
| 147 |
+
</body>
|
| 148 |
+
|
| 149 |
+
</html>
|
student_history.html
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Attendance History - Attendr</title>
|
| 8 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap"
|
| 11 |
+
rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<!-- Navigation -->
|
| 16 |
+
<!-- Navigation -->
|
| 17 |
+
{% include '_navbar.html' %}
|
| 18 |
+
|
| 19 |
+
<!-- Main Content -->
|
| 20 |
+
<section class="section">
|
| 21 |
+
<div class="container">
|
| 22 |
+
<h1 class="text-center mb-3">📚 My Attendance History</h1>
|
| 23 |
+
|
| 24 |
+
<!-- Student Info Card -->
|
| 25 |
+
<div class="glass-card mb-2">
|
| 26 |
+
<div class="flex-between">
|
| 27 |
+
<div>
|
| 28 |
+
<h3 id="studentName">Loading...</h3>
|
| 29 |
+
<p class="text-muted" id="studentId"></p>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="text-right">
|
| 32 |
+
<div style="font-size: 3rem; color: var(--color-primary);" id="totalCount">0</div>
|
| 33 |
+
<p class="text-muted">Total Attendance</p>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- Attendance Records -->
|
| 39 |
+
<div id="historyContainer">
|
| 40 |
+
<div class="text-center mt-3">
|
| 41 |
+
<div class="spinner"></div>
|
| 42 |
+
<p class="text-muted mt-2">Loading your attendance history...</p>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<!-- Empty State -->
|
| 47 |
+
<div id="emptyState" class="glass-card text-center hidden" style="padding: 3rem;">
|
| 48 |
+
<div style="font-size: 4rem; margin-bottom: 1rem;">📭</div>
|
| 49 |
+
<h2>No Attendance Records Yet</h2>
|
| 50 |
+
<p class="text-muted mt-2">You haven't marked any attendance yet. Go to the student portal to mark your
|
| 51 |
+
first attendance!</p>
|
| 52 |
+
<a href="/student" class="btn btn-primary mt-2">Mark Attendance</a>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</section>
|
| 56 |
+
|
| 57 |
+
<script>
|
| 58 |
+
document.addEventListener('DOMContentLoaded', loadHistory);
|
| 59 |
+
|
| 60 |
+
async function loadHistory() {
|
| 61 |
+
try {
|
| 62 |
+
const response = await fetch('/api/student/history');
|
| 63 |
+
const data = await response.json();
|
| 64 |
+
|
| 65 |
+
if (data.success) {
|
| 66 |
+
displayHistory(data.data);
|
| 67 |
+
} else {
|
| 68 |
+
showError(data.error);
|
| 69 |
+
}
|
| 70 |
+
} catch (error) {
|
| 71 |
+
showError('Failed to load attendance history');
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function displayHistory(data) {
|
| 76 |
+
document.getElementById('studentName').textContent = data.student.name;
|
| 77 |
+
document.getElementById('studentId').textContent = `Matric: ${data.student.student_id}`;
|
| 78 |
+
document.getElementById('totalCount').textContent = data.total_records;
|
| 79 |
+
|
| 80 |
+
const container = document.getElementById('historyContainer');
|
| 81 |
+
|
| 82 |
+
if (data.history.length === 0) {
|
| 83 |
+
container.innerHTML = '';
|
| 84 |
+
document.getElementById('emptyState').classList.remove('hidden');
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
let html = '<div class="grid grid-1 gap-2">';
|
| 89 |
+
|
| 90 |
+
data.history.forEach(record => {
|
| 91 |
+
const date = new Date(record.marked_at);
|
| 92 |
+
const sessionDate = new Date(record.session_date);
|
| 93 |
+
|
| 94 |
+
html += `
|
| 95 |
+
<div class="glass-card">
|
| 96 |
+
<div class="flex-between mb-1">
|
| 97 |
+
<div>
|
| 98 |
+
<h3>${record.course_name}</h3>
|
| 99 |
+
<p class="text-muted">Lecturer: ${record.lecturer_name}</p>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="badge badge-success">
|
| 102 |
+
✓ ${record.status.toUpperCase()}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="grid grid-2 gap-1 mt-2" style="font-size: 0.9rem;">
|
| 107 |
+
<div>
|
| 108 |
+
<strong>📍 Location:</strong><br>
|
| 109 |
+
${record.classroom} - ${record.building}
|
| 110 |
+
</div>
|
| 111 |
+
<div>
|
| 112 |
+
<strong>📅 Date:</strong><br>
|
| 113 |
+
${sessionDate.toLocaleDateString()} ${sessionDate.toLocaleTimeString()}
|
| 114 |
+
</div>
|
| 115 |
+
<div>
|
| 116 |
+
<strong>⏰ Marked At:</strong><br>
|
| 117 |
+
${date.toLocaleDateString()} ${date.toLocaleTimeString()}
|
| 118 |
+
</div>
|
| 119 |
+
<div>
|
| 120 |
+
<strong>📏 Distance:</strong><br>
|
| 121 |
+
${record.distance_from_classroom ? record.distance_from_classroom + 'm' : 'N/A'}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="flex-center gap-1 mt-2">
|
| 126 |
+
<span class="badge ${record.face_verified ? 'badge-success' : 'badge-error'}">
|
| 127 |
+
${record.face_verified ? '✓' : '✗'} Face
|
| 128 |
+
</span>
|
| 129 |
+
<span class="badge ${record.location_verified ? 'badge-success' : 'badge-error'}">
|
| 130 |
+
${record.location_verified ? '✓' : '✗'} Location
|
| 131 |
+
</span>
|
| 132 |
+
<span class="badge ${record.code_verified ? 'badge-success' : 'badge-error'}">
|
| 133 |
+
${record.code_verified ? '✓' : '✗'} Code
|
| 134 |
+
</span>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
`;
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
html += '</div>';
|
| 141 |
+
container.innerHTML = html;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function showError(message) {
|
| 145 |
+
const container = document.getElementById('historyContainer');
|
| 146 |
+
container.innerHTML = `
|
| 147 |
+
<div class="alert alert-error">
|
| 148 |
+
${message}
|
| 149 |
+
</div>
|
| 150 |
+
`;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function logout() {
|
| 154 |
+
if (!confirm('Are you sure you want to logout?')) return;
|
| 155 |
+
|
| 156 |
+
try {
|
| 157 |
+
const response = await fetch('/api/auth/logout', {
|
| 158 |
+
method: 'POST',
|
| 159 |
+
headers: { 'Content-Type': 'application/json' }
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
const data = await response.json();
|
| 163 |
+
|
| 164 |
+
if (data.success) {
|
| 165 |
+
alert(data.message);
|
| 166 |
+
window.location.href = '/';
|
| 167 |
+
} else {
|
| 168 |
+
alert('Logout failed');
|
| 169 |
+
}
|
| 170 |
+
} catch (error) {
|
| 171 |
+
alert('Logout failed');
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
</script>
|
| 175 |
+
</body>
|
| 176 |
+
|
| 177 |
+
</html>
|
style.css
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* Attendr - UTM Smart Attendance System
|
| 3 |
+
* Design Theme: UTM Universiti Teknologi Malaysia
|
| 4 |
+
* Primary Colors: Maroon (#8B1538) and Gold (#F4A900)
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
/* ==================== IMPORTS ==================== */
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@700;800&display=swap');
|
| 9 |
+
|
| 10 |
+
/* ==================== CSS VARIABLES ==================== */
|
| 11 |
+
:root {
|
| 12 |
+
/* UTM Brand Colors */
|
| 13 |
+
--color-utm-maroon: #8B1538;
|
| 14 |
+
--color-utm-maroon-dark: #6B0F2A;
|
| 15 |
+
--color-utm-maroon-light: #A01D45;
|
| 16 |
+
--color-utm-gold: #F4A900;
|
| 17 |
+
--color-utm-gold-dark: #D49000;
|
| 18 |
+
--color-utm-gold-light: #FFB820;
|
| 19 |
+
|
| 20 |
+
/* Semantic Colors */
|
| 21 |
+
--color-primary: var(--color-utm-maroon);
|
| 22 |
+
--color-primary-dark: var(--color-utm-maroon-dark);
|
| 23 |
+
--color-primary-light: var(--color-utm-maroon-light);
|
| 24 |
+
--color-accent: var(--color-utm-gold);
|
| 25 |
+
--color-accent-dark: var(--color-utm-gold-dark);
|
| 26 |
+
--color-accent-light: var(--color-utm-gold-light);
|
| 27 |
+
|
| 28 |
+
/* UI Colors */
|
| 29 |
+
--color-success: #10B981;
|
| 30 |
+
--color-error: #EF4444;
|
| 31 |
+
--color-warning: #F59E0B;
|
| 32 |
+
--color-info: #3B82F6;
|
| 33 |
+
|
| 34 |
+
/* Neutral Colors */
|
| 35 |
+
--color-bg: #0F0A15;
|
| 36 |
+
--color-bg-secondary: #1A1425;
|
| 37 |
+
--color-surface: rgba(139, 21, 56, 0.1);
|
| 38 |
+
--color-surface-hover: rgba(139, 21, 56, 0.2);
|
| 39 |
+
--color-text: #F9FAFB;
|
| 40 |
+
--color-text-muted: #D1D5DB;
|
| 41 |
+
--color-border: rgba(244, 169, 0, 0.2);
|
| 42 |
+
|
| 43 |
+
/* Spacing */
|
| 44 |
+
--spacing-xs: 0.25rem;
|
| 45 |
+
--spacing-sm: 0.5rem;
|
| 46 |
+
--spacing-md: 1rem;
|
| 47 |
+
--spacing-lg: 1.5rem;
|
| 48 |
+
--spacing-xl: 2rem;
|
| 49 |
+
--spacing-2xl: 3rem;
|
| 50 |
+
|
| 51 |
+
/* Border Radius */
|
| 52 |
+
--radius-sm: 0.375rem;
|
| 53 |
+
--radius-md: 0.5rem;
|
| 54 |
+
--radius-lg: 0.75rem;
|
| 55 |
+
--radius-xl: 1rem;
|
| 56 |
+
|
| 57 |
+
/* Shadows */
|
| 58 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 59 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 60 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
| 61 |
+
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
| 62 |
+
|
| 63 |
+
/* Transitions */
|
| 64 |
+
--transition-fast: 150ms ease-in-out;
|
| 65 |
+
--transition-base: 300ms ease-in-out;
|
| 66 |
+
--transition-slow: 500ms ease-in-out;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* ==================== RESET & BASE ==================== */
|
| 70 |
+
* {
|
| 71 |
+
margin: 0;
|
| 72 |
+
padding: 0;
|
| 73 |
+
box-sizing: border-box;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
body {
|
| 77 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 78 |
+
background: linear-gradient(135deg, #0F0A15 0%, #1A1425 50%, #0F0A15 100%);
|
| 79 |
+
background-attachment: fixed;
|
| 80 |
+
color: var(--color-text);
|
| 81 |
+
line-height: 1.6;
|
| 82 |
+
min-height: 100vh;
|
| 83 |
+
overflow-x: hidden;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* UTM Pattern Background */
|
| 87 |
+
body::before {
|
| 88 |
+
content: '';
|
| 89 |
+
position: fixed;
|
| 90 |
+
top: 0;
|
| 91 |
+
left: 0;
|
| 92 |
+
width: 100%;
|
| 93 |
+
height: 100%;
|
| 94 |
+
background-image:
|
| 95 |
+
radial-gradient(circle at 20% 50%, rgba(139, 21, 56, 0.1) 0%, transparent 50%),
|
| 96 |
+
radial-gradient(circle at 80% 80%, rgba(244, 169, 0, 0.1) 0%, transparent 50%);
|
| 97 |
+
pointer-events: none;
|
| 98 |
+
z-index: 0;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* ==================== TYPOGRAPHY ==================== */
|
| 102 |
+
h1,
|
| 103 |
+
h2,
|
| 104 |
+
h3,
|
| 105 |
+
h4,
|
| 106 |
+
h5,
|
| 107 |
+
h6 {
|
| 108 |
+
font-family: 'Outfit', 'Inter', sans-serif;
|
| 109 |
+
font-weight: 700;
|
| 110 |
+
line-height: 1.2;
|
| 111 |
+
color: var(--color-text);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
h1 {
|
| 115 |
+
font-size: 2.5rem;
|
| 116 |
+
background: linear-gradient(135deg, var(--color-utm-maroon) 0%, var(--color-utm-gold) 100%);
|
| 117 |
+
-webkit-background-clip: text;
|
| 118 |
+
-webkit-text-fill-color: transparent;
|
| 119 |
+
background-clip: text;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
h2 {
|
| 123 |
+
font-size: 2rem;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
h3 {
|
| 127 |
+
font-size: 1.5rem;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* ==================== LAYOUT ==================== */
|
| 131 |
+
.container {
|
| 132 |
+
max-width: 1200px;
|
| 133 |
+
margin: 0 auto;
|
| 134 |
+
padding: 0 var(--spacing-lg);
|
| 135 |
+
position: relative;
|
| 136 |
+
z-index: 1;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.section {
|
| 140 |
+
padding: var(--spacing-2xl) 0;
|
| 141 |
+
position: relative;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* ==================== NAVIGATION ==================== */
|
| 145 |
+
.navbar {
|
| 146 |
+
background: rgba(15, 10, 21, 0.95);
|
| 147 |
+
backdrop-filter: blur(10px);
|
| 148 |
+
border-bottom: 2px solid var(--color-utm-gold);
|
| 149 |
+
padding: var(--spacing-md) 0;
|
| 150 |
+
position: sticky;
|
| 151 |
+
top: 0;
|
| 152 |
+
z-index: 1000;
|
| 153 |
+
box-shadow: 0 4px 20px rgba(139, 21, 56, 0.3);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.navbar-content {
|
| 157 |
+
display: flex;
|
| 158 |
+
justify-content: space-between;
|
| 159 |
+
align-items: center;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.navbar-brand {
|
| 163 |
+
font-family: 'Outfit', sans-serif;
|
| 164 |
+
font-size: 1.75rem;
|
| 165 |
+
font-weight: 800;
|
| 166 |
+
background: linear-gradient(135deg, var(--color-utm-maroon) 0%, var(--color-utm-gold) 100%);
|
| 167 |
+
-webkit-background-clip: text;
|
| 168 |
+
-webkit-text-fill-color: transparent;
|
| 169 |
+
background-clip: text;
|
| 170 |
+
text-decoration: none;
|
| 171 |
+
transition: transform var(--transition-fast);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.navbar-brand:hover {
|
| 175 |
+
transform: scale(1.05);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.navbar-nav {
|
| 179 |
+
display: flex;
|
| 180 |
+
list-style: none;
|
| 181 |
+
gap: var(--spacing-lg);
|
| 182 |
+
align-items: center;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.navbar-link {
|
| 186 |
+
color: var(--color-text);
|
| 187 |
+
text-decoration: none;
|
| 188 |
+
font-weight: 500;
|
| 189 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 190 |
+
border-radius: var(--radius-md);
|
| 191 |
+
transition: all var(--transition-fast);
|
| 192 |
+
position: relative;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.navbar-link::after {
|
| 196 |
+
content: '';
|
| 197 |
+
position: absolute;
|
| 198 |
+
bottom: 0;
|
| 199 |
+
left: 50%;
|
| 200 |
+
transform: translateX(-50%);
|
| 201 |
+
width: 0;
|
| 202 |
+
height: 2px;
|
| 203 |
+
background: var(--color-utm-gold);
|
| 204 |
+
transition: width var(--transition-fast);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.navbar-link:hover {
|
| 208 |
+
color: var(--color-utm-gold);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.navbar-link:hover::after {
|
| 212 |
+
width: 80%;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* ==================== GLASS CARD ==================== */
|
| 216 |
+
.glass-card {
|
| 217 |
+
background: rgba(26, 20, 37, 0.7);
|
| 218 |
+
backdrop-filter: blur(10px);
|
| 219 |
+
border: 1px solid var(--color-border);
|
| 220 |
+
border-radius: var(--radius-xl);
|
| 221 |
+
padding: var(--spacing-xl);
|
| 222 |
+
box-shadow:
|
| 223 |
+
0 8px 32px rgba(139, 21, 56, 0.2),
|
| 224 |
+
inset 0 1px 0 rgba(244, 169, 0, 0.1);
|
| 225 |
+
transition: all var(--transition-base);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.glass-card:hover {
|
| 229 |
+
transform: translateY(-2px);
|
| 230 |
+
box-shadow:
|
| 231 |
+
0 12px 40px rgba(139, 21, 56, 0.3),
|
| 232 |
+
inset 0 1px 0 rgba(244, 169, 0, 0.2);
|
| 233 |
+
border-color: rgba(244, 169, 0, 0.4);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* ==================== BUTTONS ==================== */
|
| 237 |
+
.btn {
|
| 238 |
+
display: inline-block;
|
| 239 |
+
padding: 0.75rem 1.5rem;
|
| 240 |
+
font-size: 1rem;
|
| 241 |
+
font-weight: 600;
|
| 242 |
+
text-align: center;
|
| 243 |
+
text-decoration: none;
|
| 244 |
+
border: none;
|
| 245 |
+
border-radius: var(--radius-md);
|
| 246 |
+
cursor: pointer;
|
| 247 |
+
transition: all var(--transition-fast);
|
| 248 |
+
position: relative;
|
| 249 |
+
overflow: hidden;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.btn::before {
|
| 253 |
+
content: '';
|
| 254 |
+
position: absolute;
|
| 255 |
+
top: 50%;
|
| 256 |
+
left: 50%;
|
| 257 |
+
width: 0;
|
| 258 |
+
height: 0;
|
| 259 |
+
border-radius: 50%;
|
| 260 |
+
background: rgba(255, 255, 255, 0.1);
|
| 261 |
+
transform: translate(-50%, -50%);
|
| 262 |
+
transition: width 0.6s, height 0.6s;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.btn:hover::before {
|
| 266 |
+
width: 300px;
|
| 267 |
+
height: 300px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.btn-primary {
|
| 271 |
+
background: linear-gradient(135deg, var(--color-utm-maroon) 0%, var(--color-utm-maroon-dark) 100%);
|
| 272 |
+
color: white;
|
| 273 |
+
box-shadow: 0 4px 15px rgba(139, 21, 56, 0.4);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.btn-primary:hover {
|
| 277 |
+
background: linear-gradient(135deg, var(--color-utm-maroon-light) 0%, var(--color-utm-maroon) 100%);
|
| 278 |
+
box-shadow: 0 6px 20px rgba(139, 21, 56, 0.6);
|
| 279 |
+
transform: translateY(-2px);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.btn-secondary {
|
| 283 |
+
background: linear-gradient(135deg, var(--color-utm-gold) 0%, var(--color-utm-gold-dark) 100%);
|
| 284 |
+
color: var(--color-utm-maroon-dark);
|
| 285 |
+
box-shadow: 0 4px 15px rgba(244, 169, 0, 0.4);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.btn-secondary:hover {
|
| 289 |
+
background: linear-gradient(135deg, var(--color-utm-gold-light) 0%, var(--color-utm-gold) 100%);
|
| 290 |
+
box-shadow: 0 6px 20px rgba(244, 169, 0, 0.6);
|
| 291 |
+
transform: translateY(-2px);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.btn-success {
|
| 295 |
+
background: linear-gradient(135deg, var(--color-success) 0%, #059669 100%);
|
| 296 |
+
color: white;
|
| 297 |
+
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.btn-success:hover {
|
| 301 |
+
background: linear-gradient(135deg, #10B981 0%, var(--color-success) 100%);
|
| 302 |
+
transform: translateY(-2px);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.btn-outline {
|
| 306 |
+
background: transparent;
|
| 307 |
+
color: var(--color-utm-gold);
|
| 308 |
+
border: 2px solid var(--color-utm-gold);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.btn-outline:hover {
|
| 312 |
+
background: var(--color-utm-gold);
|
| 313 |
+
color: var(--color-utm-maroon-dark);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.btn:disabled {
|
| 317 |
+
opacity: 0.5;
|
| 318 |
+
cursor: not-allowed;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/* ==================== FORMS ==================== */
|
| 322 |
+
.form-group {
|
| 323 |
+
margin-bottom: var(--spacing-lg);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.form-label {
|
| 327 |
+
display: block;
|
| 328 |
+
margin-bottom: var(--spacing-sm);
|
| 329 |
+
font-weight: 600;
|
| 330 |
+
color: var(--color-utm-gold);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.form-input,
|
| 334 |
+
.form-select {
|
| 335 |
+
width: 100%;
|
| 336 |
+
padding: 0.75rem 1rem;
|
| 337 |
+
background: rgba(26, 20, 37, 0.6);
|
| 338 |
+
border: 1px solid var(--color-border);
|
| 339 |
+
border-radius: var(--radius-md);
|
| 340 |
+
color: var(--color-text);
|
| 341 |
+
font-size: 1rem;
|
| 342 |
+
transition: all var(--transition-fast);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.form-input:focus,
|
| 346 |
+
.form-select:focus {
|
| 347 |
+
outline: none;
|
| 348 |
+
border-color: var(--color-utm-gold);
|
| 349 |
+
box-shadow: 0 0 0 3px rgba(244, 169, 0, 0.1);
|
| 350 |
+
background: rgba(26, 20, 37, 0.8);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.form-select option {
|
| 354 |
+
background: #1A1425;
|
| 355 |
+
color: var(--color-text);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* ==================== BADGES ==================== */
|
| 359 |
+
.badge {
|
| 360 |
+
display: inline-block;
|
| 361 |
+
padding: 0.25rem 0.75rem;
|
| 362 |
+
font-size: 0.875rem;
|
| 363 |
+
font-weight: 600;
|
| 364 |
+
border-radius: var(--radius-sm);
|
| 365 |
+
background: rgba(139, 21, 56, 0.2);
|
| 366 |
+
color: var(--color-utm-gold);
|
| 367 |
+
border: 1px solid var(--color-utm-gold);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.badge-success {
|
| 371 |
+
background: rgba(16, 185, 129, 0.2);
|
| 372 |
+
color: var(--color-success);
|
| 373 |
+
border-color: var(--color-success);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.badge-error {
|
| 377 |
+
background: rgba(239, 68, 68, 0.2);
|
| 378 |
+
color: var(--color-error);
|
| 379 |
+
border-color: var(--color-error);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* ==================== CODE DISPLAY ==================== */
|
| 383 |
+
.code-display {
|
| 384 |
+
font-family: 'Courier New', monospace;
|
| 385 |
+
font-size: 4rem;
|
| 386 |
+
font-weight: 800;
|
| 387 |
+
text-align: center;
|
| 388 |
+
padding: var(--spacing-xl);
|
| 389 |
+
background: linear-gradient(135deg, var(--color-utm-maroon) 0%, var(--color-utm-gold) 100%);
|
| 390 |
+
-webkit-background-clip: text;
|
| 391 |
+
-webkit-text-fill-color: transparent;
|
| 392 |
+
background-clip: text;
|
| 393 |
+
letter-spacing: 1rem;
|
| 394 |
+
text-shadow: 0 0 30px rgba(244, 169, 0, 0.5);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
/* ==================== UTILITIES ==================== */
|
| 398 |
+
.text-center {
|
| 399 |
+
text-align: center;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.text-right {
|
| 403 |
+
text-align: right;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.text-muted {
|
| 407 |
+
color: var(--color-text-muted);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.mt-1 {
|
| 411 |
+
margin-top: var(--spacing-sm);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.mt-2 {
|
| 415 |
+
margin-top: var(--spacing-md);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.mt-3 {
|
| 419 |
+
margin-top: var(--spacing-lg);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.mb-1 {
|
| 423 |
+
margin-bottom: var(--spacing-sm);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.mb-2 {
|
| 427 |
+
margin-bottom: var(--spacing-md);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.mb-3 {
|
| 431 |
+
margin-bottom: var(--spacing-lg);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.hidden {
|
| 435 |
+
display: none !important;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.grid {
|
| 439 |
+
display: grid;
|
| 440 |
+
gap: var(--spacing-lg);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.grid-1 {
|
| 444 |
+
grid-template-columns: 1fr;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.grid-2 {
|
| 448 |
+
grid-template-columns: repeat(2, 1fr);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.grid-3 {
|
| 452 |
+
grid-template-columns: repeat(3, 1fr);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.gap-1 {
|
| 456 |
+
gap: var(--spacing-sm);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.gap-2 {
|
| 460 |
+
gap: var(--spacing-md);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.flex-between {
|
| 464 |
+
display: flex;
|
| 465 |
+
justify-content: space-between;
|
| 466 |
+
align-items: center;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.flex-center {
|
| 470 |
+
display: flex;
|
| 471 |
+
justify-content: center;
|
| 472 |
+
align-items: center;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/* ==================== LOADING & OVERLAYS ==================== */
|
| 476 |
+
.loading-overlay {
|
| 477 |
+
position: fixed;
|
| 478 |
+
top: 0;
|
| 479 |
+
left: 0;
|
| 480 |
+
width: 100%;
|
| 481 |
+
height: 100%;
|
| 482 |
+
background: rgba(15, 10, 21, 0.9);
|
| 483 |
+
display: flex;
|
| 484 |
+
justify-content: center;
|
| 485 |
+
align-items: center;
|
| 486 |
+
z-index: 9999;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.spinner {
|
| 490 |
+
width: 50px;
|
| 491 |
+
height: 50px;
|
| 492 |
+
border: 4px solid rgba(244, 169, 0, 0.2);
|
| 493 |
+
border-top-color: var(--color-utm-gold);
|
| 494 |
+
border-radius: 50%;
|
| 495 |
+
animation: spin 1s linear infinite;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
@keyframes spin {
|
| 499 |
+
to {
|
| 500 |
+
transform: rotate(360deg);
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/* ==================== ALERTS ==================== */
|
| 505 |
+
.alert {
|
| 506 |
+
padding: var(--spacing-md);
|
| 507 |
+
border-radius: var(--radius-md);
|
| 508 |
+
margin-bottom: var(--spacing-md);
|
| 509 |
+
border-left: 4px solid;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.alert-error {
|
| 513 |
+
background: rgba(239, 68, 68, 0.1);
|
| 514 |
+
border-color: var(--color-error);
|
| 515 |
+
color: #FCA5A5;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.alert-success {
|
| 519 |
+
background: rgba(16, 185, 129, 0.1);
|
| 520 |
+
border-color: var(--color-success);
|
| 521 |
+
color: #6EE7B7;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
/* ==================== VIDEO & MEDIA ==================== */
|
| 525 |
+
.video-container {
|
| 526 |
+
position: relative;
|
| 527 |
+
border-radius: var(--radius-lg);
|
| 528 |
+
overflow: hidden;
|
| 529 |
+
border: 2px solid var(--color-utm-gold);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.video-preview {
|
| 533 |
+
width: 100%;
|
| 534 |
+
height: auto;
|
| 535 |
+
display: block;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
/* ==================== STATUS BADGES ==================== */
|
| 539 |
+
.status-badge {
|
| 540 |
+
padding: var(--spacing-md);
|
| 541 |
+
border-radius: var(--radius-md);
|
| 542 |
+
border: 1px solid var(--color-border);
|
| 543 |
+
background: rgba(26, 20, 37, 0.6);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.status-info {
|
| 547 |
+
border-color: var(--color-utm-gold);
|
| 548 |
+
background: rgba(244, 169, 0, 0.1);
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/* ==================== RESPONSIVE ==================== */
|
| 552 |
+
@media (max-width: 768px) {
|
| 553 |
+
|
| 554 |
+
.grid-2,
|
| 555 |
+
.grid-3 {
|
| 556 |
+
grid-template-columns: 1fr;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.navbar-nav {
|
| 560 |
+
gap: var(--spacing-sm);
|
| 561 |
+
font-size: 0.875rem;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
h1 {
|
| 565 |
+
font-size: 2rem;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
h2 {
|
| 569 |
+
font-size: 1.5rem;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.code-display {
|
| 573 |
+
font-size: 2.5rem;
|
| 574 |
+
letter-spacing: 0.5rem;
|
| 575 |
+
}
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
/* ==================== ANIMATIONS ==================== */
|
| 579 |
+
@keyframes fadeIn {
|
| 580 |
+
from {
|
| 581 |
+
opacity: 0;
|
| 582 |
+
transform: translateY(20px);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
to {
|
| 586 |
+
opacity: 1;
|
| 587 |
+
transform: translateY(0);
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.glass-card {
|
| 592 |
+
animation: fadeIn 0.5s ease-out;
|
| 593 |
+
}
|
update_utm_coordinates.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Update all UTM Faculty of Computing venues to correct GPS coordinates
|
| 3 |
+
Based on Plus Code: HJ7Q+F5 Johor Bahru (UTM Skudai Campus)
|
| 4 |
+
Coordinates: 1.5638°N, 103.6388°E
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from app import app, db
|
| 8 |
+
from models import Classroom
|
| 9 |
+
|
| 10 |
+
def update_utm_coordinates():
|
| 11 |
+
"""Update all UTM venues to correct coordinates, except PUTERI-COURT"""
|
| 12 |
+
|
| 13 |
+
with app.app_context():
|
| 14 |
+
print("Updating UTM Faculty of Computing venue coordinates...")
|
| 15 |
+
print("Base location: HJ7Q+F5 Johor Bahru (UTM Skudai)")
|
| 16 |
+
print("=" * 70)
|
| 17 |
+
|
| 18 |
+
# Base coordinates for UTM FC (from Plus Code HJ7Q+F5)
|
| 19 |
+
BASE_LAT = 1.5638
|
| 20 |
+
BASE_LON = 103.6388
|
| 21 |
+
|
| 22 |
+
# Get all venues except PUTERI-COURT
|
| 23 |
+
utm_venues = Classroom.query.filter(Classroom.name != 'PUTERI-COURT').all()
|
| 24 |
+
|
| 25 |
+
if not utm_venues:
|
| 26 |
+
print("No UTM venues found to update!")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
updated_count = 0
|
| 30 |
+
|
| 31 |
+
# Update each venue with slight offset from base coordinates
|
| 32 |
+
for idx, venue in enumerate(utm_venues):
|
| 33 |
+
# Create slight variations for each venue (small offsets)
|
| 34 |
+
# This simulates different rooms being in slightly different locations
|
| 35 |
+
lat_offset = (idx % 10) * 0.0001 # Small latitude variation
|
| 36 |
+
lon_offset = (idx // 10) * 0.0001 # Small longitude variation
|
| 37 |
+
|
| 38 |
+
venue.latitude = BASE_LAT + lat_offset
|
| 39 |
+
venue.longitude = BASE_LON + lon_offset
|
| 40 |
+
venue.radius_meters = 50 # Keep 50m radius for classrooms
|
| 41 |
+
|
| 42 |
+
updated_count += 1
|
| 43 |
+
print(f"Updated: {venue.name} -> ({venue.latitude:.6f}, {venue.longitude:.6f})")
|
| 44 |
+
|
| 45 |
+
# Commit all changes
|
| 46 |
+
db.session.commit()
|
| 47 |
+
|
| 48 |
+
print("=" * 70)
|
| 49 |
+
print(f"[OK] Successfully updated {updated_count} UTM venues")
|
| 50 |
+
print(f"Base coordinates: {BASE_LAT}N, {BASE_LON}E")
|
| 51 |
+
print(f"PUTERI-COURT was NOT updated (kept separate)")
|
| 52 |
+
print("=" * 70)
|
| 53 |
+
|
| 54 |
+
# Verify PUTERI-COURT wasn't changed
|
| 55 |
+
puteri = Classroom.query.filter_by(name='PUTERI-COURT').first()
|
| 56 |
+
if puteri:
|
| 57 |
+
print(f"\n[OK] PUTERI-COURT location verified:")
|
| 58 |
+
print(f" Latitude: {puteri.latitude}")
|
| 59 |
+
print(f" Longitude: {puteri.longitude}")
|
| 60 |
+
print(f" (Jalan Raja Chulan, KL - unchanged)")
|
| 61 |
+
|
| 62 |
+
print(f"\nTotal venues in database: {Classroom.query.count()}")
|
| 63 |
+
|
| 64 |
+
if __name__ == '__main__':
|
| 65 |
+
update_utm_coordinates()
|
utils.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import io
|
| 5 |
+
from math import radians, cos, sin, asin, sqrt
|
| 6 |
+
|
| 7 |
+
def base64_to_image(base64_string):
|
| 8 |
+
"""Convert base64 string to PIL Image"""
|
| 9 |
+
try:
|
| 10 |
+
# Remove data URL prefix if present
|
| 11 |
+
if ',' in base64_string:
|
| 12 |
+
base64_string = base64_string.split(',')[1]
|
| 13 |
+
|
| 14 |
+
# Decode base64
|
| 15 |
+
image_data = base64.b64decode(base64_string)
|
| 16 |
+
image = Image.open(io.BytesIO(image_data))
|
| 17 |
+
|
| 18 |
+
# Convert to RGB if necessary
|
| 19 |
+
if image.mode != 'RGB':
|
| 20 |
+
image = image.convert('RGB')
|
| 21 |
+
|
| 22 |
+
return image
|
| 23 |
+
except Exception as e:
|
| 24 |
+
raise ValueError(f"Failed to decode base64 image: {str(e)}")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def image_to_numpy(image):
|
| 28 |
+
"""Convert PIL Image to numpy array"""
|
| 29 |
+
return np.array(image)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def haversine_distance(lat1, lon1, lat2, lon2):
|
| 33 |
+
"""
|
| 34 |
+
Calculate the great circle distance between two points
|
| 35 |
+
on the earth (specified in decimal degrees)
|
| 36 |
+
Returns distance in meters
|
| 37 |
+
"""
|
| 38 |
+
# Convert decimal degrees to radians
|
| 39 |
+
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
| 40 |
+
|
| 41 |
+
# Haversine formula
|
| 42 |
+
dlat = lat2 - lat1
|
| 43 |
+
dlon = lon2 - lon1
|
| 44 |
+
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
| 45 |
+
c = 2 * asin(sqrt(a))
|
| 46 |
+
|
| 47 |
+
# Radius of earth in meters
|
| 48 |
+
r = 6371000
|
| 49 |
+
|
| 50 |
+
return c * r
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def format_error_response(message, code=400):
|
| 54 |
+
"""Format error response"""
|
| 55 |
+
return {
|
| 56 |
+
'success': False,
|
| 57 |
+
'error': message
|
| 58 |
+
}, code
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def format_success_response(data=None, message=None):
|
| 62 |
+
"""Format success response"""
|
| 63 |
+
response = {'success': True}
|
| 64 |
+
if message:
|
| 65 |
+
response['message'] = message
|
| 66 |
+
if data:
|
| 67 |
+
response['data'] = data
|
| 68 |
+
return response, 200
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def validate_coordinates(latitude, longitude):
|
| 72 |
+
"""Validate GPS coordinates"""
|
| 73 |
+
try:
|
| 74 |
+
lat = float(latitude)
|
| 75 |
+
lon = float(longitude)
|
| 76 |
+
|
| 77 |
+
if not (-90 <= lat <= 90):
|
| 78 |
+
return False, "Latitude must be between -90 and 90"
|
| 79 |
+
if not (-180 <= lon <= 180):
|
| 80 |
+
return False, "Longitude must be between -180 and 180"
|
| 81 |
+
|
| 82 |
+
return True, None
|
| 83 |
+
except (ValueError, TypeError):
|
| 84 |
+
return False, "Invalid coordinate format"
|