Pratham200Rajbhar commited on
Commit
78013c4
·
0 Parent(s):

first commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/backend-ci.yml +82 -0
  2. .github/workflows/frontend-ci.yml +46 -0
  3. .gitignore +35 -0
  4. README.md +165 -0
  5. backend/.env.example +23 -0
  6. backend/.gitignore +66 -0
  7. backend/.python-version +1 -0
  8. backend/app/__init__.py +13 -0
  9. backend/app/api/admin.py +388 -0
  10. backend/app/api/auth.py +140 -0
  11. backend/app/api/dependencies.py +108 -0
  12. backend/app/api/logs.py +20 -0
  13. backend/app/api/student.py +308 -0
  14. backend/app/api/teacher.py +247 -0
  15. backend/app/api/ws.py +177 -0
  16. backend/app/core/config.py +41 -0
  17. backend/app/core/logging_config.py +37 -0
  18. backend/app/core/security.py +46 -0
  19. backend/app/db/client.py +24 -0
  20. backend/app/db/redis.py +39 -0
  21. backend/app/middleware/request_logging.py +25 -0
  22. backend/app/repositories/attendance_repo.py +64 -0
  23. backend/app/repositories/class_repo.py +12 -0
  24. backend/app/repositories/enrollment_repo.py +19 -0
  25. backend/app/repositories/geofence_repo.py +18 -0
  26. backend/app/repositories/leave_repo.py +44 -0
  27. backend/app/repositories/session_repo.py +34 -0
  28. backend/app/repositories/student_repo.py +47 -0
  29. backend/app/repositories/system_config_repo.py +40 -0
  30. backend/app/repositories/teacher_repo.py +10 -0
  31. backend/app/repositories/user_repo.py +13 -0
  32. backend/app/schemas/admin.py +97 -0
  33. backend/app/schemas/attendance.py +56 -0
  34. backend/app/schemas/auth.py +31 -0
  35. backend/app/schemas/leave.py +49 -0
  36. backend/app/schemas/log.py +35 -0
  37. backend/app/schemas/master_data.py +65 -0
  38. backend/app/schemas/student.py +81 -0
  39. backend/app/schemas/system_config.py +16 -0
  40. backend/app/schemas/teacher.py +176 -0
  41. backend/app/services/absentee_scanner.py +56 -0
  42. backend/app/services/admin_service.py +361 -0
  43. backend/app/services/ai_orchestrator.py +174 -0
  44. backend/app/services/attendance_service.py +465 -0
  45. backend/app/services/auth_service.py +56 -0
  46. backend/app/services/device_change_service.py +105 -0
  47. backend/app/services/gamification_service.py +293 -0
  48. backend/app/services/leave_service.py +65 -0
  49. backend/app/services/notification_service.py +70 -0
  50. backend/app/services/session_service.py +115 -0
.github/workflows/backend-ci.yml ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Backend CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ paths:
9
+ - 'backend/**'
10
+ - '.github/workflows/backend-ci.yml'
11
+ pull_request:
12
+ branches:
13
+ - main
14
+ - master
15
+ paths:
16
+ - 'backend/**'
17
+ - '.github/workflows/backend-ci.yml'
18
+
19
+ jobs:
20
+ lint-and-test:
21
+ name: Lint & Verify Backend
22
+ runs-on: ubuntu-latest
23
+
24
+ defaults:
25
+ run:
26
+ working-directory: backend
27
+
28
+ steps:
29
+ - name: Checkout Code
30
+ uses: actions/checkout@v4
31
+
32
+ - name: Setup Python
33
+ uses: actions/setup-python@v5
34
+ with:
35
+ python-version: '3.11'
36
+ cache: 'pip'
37
+ cache-dependency-path: backend/requirements.txt
38
+
39
+ - name: Setup Node.js
40
+ uses: actions/setup-node@v4
41
+ with:
42
+ node-version: '20'
43
+
44
+ - name: Install System Dependencies
45
+ run: |
46
+ sudo apt-get update
47
+ sudo apt-get install -y --no-install-recommends build-essential libpq-dev
48
+
49
+ - name: Install Python Dependencies
50
+ run: |
51
+ python -m pip install --upgrade pip
52
+ pip install -r requirements.txt
53
+ pip install ruff
54
+
55
+ - name: Generate Prisma Client
56
+ run: |
57
+ python -m prisma generate
58
+
59
+ - name: Run Ruff Linter
60
+ run: |
61
+ ruff check app/ main.py
62
+
63
+ docker-build:
64
+ name: Verify Docker Build
65
+ runs-on: ubuntu-latest
66
+ needs: lint-and-test
67
+
68
+ steps:
69
+ - name: Checkout Code
70
+ uses: actions/checkout@v4
71
+
72
+ - name: Set up Docker Buildx
73
+ uses: docker/setup-buildx-action@v3
74
+
75
+ - name: Build Docker Image
76
+ uses: docker/build-push-action@v5
77
+ with:
78
+ context: backend
79
+ file: backend/Dockerfile
80
+ push: false
81
+ cache-from: type=gha
82
+ cache-to: type=gha,mode=max
.github/workflows/frontend-ci.yml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Frontend CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ paths:
9
+ - 'frontend/**'
10
+ - '.github/workflows/frontend-ci.yml'
11
+ pull_request:
12
+ branches:
13
+ - main
14
+ - master
15
+ paths:
16
+ - 'frontend/**'
17
+ - '.github/workflows/frontend-ci.yml'
18
+
19
+ jobs:
20
+ build:
21
+ name: Lint & Build Frontend
22
+ runs-on: ubuntu-latest
23
+
24
+ defaults:
25
+ run:
26
+ working-directory: frontend
27
+
28
+ steps:
29
+ - name: Checkout Code
30
+ uses: actions/checkout@v4
31
+
32
+ - name: Setup Node.js
33
+ uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '20'
36
+ cache: 'npm'
37
+ cache-dependency-path: frontend/package-lock.json
38
+
39
+ - name: Install Dependencies
40
+ run: npm ci
41
+
42
+ - name: Run Linter
43
+ run: npm run lint
44
+
45
+ - name: Build Application
46
+ run: npm run build
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OS Specific
2
+ .DS_Store
3
+ .DS_Store?
4
+ ._*
5
+ .Spotlight-V100
6
+ .Trashes
7
+ ehthumbs.db
8
+ Thumbs.db
9
+ desktop.ini
10
+
11
+ # IDEs and Editors
12
+ .vscode/
13
+ !.vscode/extensions.json
14
+ .idea/
15
+ *.suo
16
+ *.ntvs*
17
+ *.njsproj
18
+ *.sln
19
+ *.swp
20
+ *.swo
21
+ *~
22
+ .project
23
+ .classpath
24
+ .cproject
25
+ .settings/
26
+
27
+ # Logs
28
+ logs/
29
+ *.log
30
+
31
+ # Local/Private
32
+ .env
33
+ .env.local
34
+ .env.*.local
35
+ .env*
README.md ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smart Attendance System
2
+
3
+ An asynchronous, AI-powered multi-layered attendance verification system designed to eliminate buddy punching, proxy check-ins, and attendance fraud. The project consists of a FastAPI backend using TensorFlow/DeepFace, a Next.js web application for administration/teachers, and a Flutter mobile application for students.
4
+
5
+ ---
6
+
7
+ ## 🚀 Key Features
8
+
9
+ ### 1. Multi-Layered AI Verification
10
+ To mark attendance, the student uploads a live selfie which undergoes three independent stages of verification:
11
+ - **Facial Recognition**: Matches the student's live face embedding against their registered template using the **FaceNet** model (via **DeepFace**) with **128-dimensional** vector embeddings stored in PostgreSQL using `pgvector`.
12
+ - **Liveness Detection**: Employs a custom-trained **MobileNetV2** model to check if the submission is a real person, preventing spoofing attempts using photos, videos, or masks.
13
+ - **Background Validation**: Utilizes a custom **MobileNetV1** model to verify that the background of the image matches the expected classroom environment.
14
+
15
+ ### 2. Location & Geofencing
16
+ - Verifies student's physical location against active class coordinates.
17
+ - Teachers define geofenced regions (latitude, longitude, and radius in meters).
18
+ - Submissions outside the geofence boundary are automatically flagged or rejected.
19
+
20
+ ### 3. Device Binding (Anti-Proxy)
21
+ - Restricts each student account to a single mobile device.
22
+ - Generates and binds a unique hardware UUID (`device_uuid`) on first login.
23
+ - Students must submit a **Device Change Request** to be approved by administrators/teachers before they can log in on a new device.
24
+
25
+ ### 4. Real-time Communication & Notifications
26
+ - Websocket-based live connection to push real-time attendance updates to teachers' dashboards.
27
+ - Firebase Cloud Messaging (FCM) integration to dispatch push notifications for new sessions, reminders, and leave status updates.
28
+
29
+ ### 5. Gamification Suite
30
+ - Encourages student attendance through engagement features including current/highest streaks, levels, leaderboards, and point systems.
31
+
32
+ ---
33
+
34
+ ## 🛠️ Technology Stack & Versions
35
+
36
+ | Layer | Technology | Version / Specification | Key Libraries |
37
+ | :--- | :--- | :--- | :--- |
38
+ | **Backend** | Python 3.11 / FastAPI | `0.115.6` | Prisma ORM, TensorFlow `2.15.0`, DeepFace `0.0.93`, OpenCV `4.10.0`, Redis `5.2.1` |
39
+ | **Frontend** | Next.js (React 19) | `16.2.6` | Tailwind CSS `4.x`, Recharts `3.8.1`, Zustand `5.0.13`, Leaflet Map `1.9.4` |
40
+ | **Mobile** | Flutter SDK | `^3.8.0` | Riverpod `^2.6.1`, Dio `^5.7.0`, Geolocator `^13.0.2`, Hive `^2.2.3` |
41
+ | **Database** | PostgreSQL | 15+ | `pgvector` extension enabled for biometric representations |
42
+
43
+ ---
44
+
45
+ ## 📂 Project Structure
46
+
47
+ ```
48
+ .
49
+ ├── backend/ # FastAPI python application, database migrations, and AI models
50
+ │ ├── app/ # Application source code (api, core, db, middleware, services, etc.)
51
+ │ ├── models/ # Local folder for downloading/caching TF models
52
+ │ ├── prisma/ # Prisma schema and seeding configurations
53
+ │ └── main.py # App entrypoint
54
+ ├── frontend/ # Next.js web application for admins and teachers
55
+ │ ├── src/ # Next.js pages/components
56
+ │ └── package.json # Frontend dependency definitions
57
+ └── mobile/ # Flutter student companion app
58
+ ├── lib/ # Flutter implementation source code
59
+ └── pubspec.yaml # Flutter dependency configuration
60
+ ```
61
+
62
+ ---
63
+
64
+ ## ⚙️ Getting Started
65
+
66
+ ### Prerequisites
67
+ 1. **Python 3.11** installed on the host system.
68
+ 2. **Node.js 20+** and **npm** installed.
69
+ 3. **Flutter SDK (v3.8.x+)** and target development environment (Android/iOS simulator or physical device).
70
+ 4. **PostgreSQL** database with `pgvector` extension enabled.
71
+ 5. **Redis Server** running locally or accessible via network.
72
+ 6. A **HuggingFace** token (`HF_TOKEN`) to download pre-trained liveness & background models.
73
+
74
+ ---
75
+
76
+ ### 1. Backend Setup
77
+
78
+ 1. **Navigate to the directory**:
79
+ ```bash
80
+ cd backend
81
+ ```
82
+
83
+ 2. **Configure environment variables**:
84
+ Create a `.env` file by copying the template:
85
+ ```bash
86
+ cp .env.example .env
87
+ ```
88
+ Fill in the required fields (database connection strings, Redis URL, JWT Secret, and HF token if required).
89
+
90
+ 3. **Install dependencies**:
91
+ ```bash
92
+ pip install -r requirements.txt
93
+ ```
94
+
95
+ 4. **Prepare the database (Prisma)**:
96
+ Ensure your PostgreSQL service is running and has the `pgvector` extension enabled, then run:
97
+ ```bash
98
+ python -m prisma db push
99
+ python -m prisma generate
100
+ ```
101
+
102
+ 5. **Seed the database (Optional)**:
103
+ ```bash
104
+ python prisma/seed.py
105
+ ```
106
+
107
+ 6. **Start the server**:
108
+ ```bash
109
+ uvicorn main:app --reload --port 8000
110
+ ```
111
+ Interactive API documentation will be available at [http://localhost:8000/docs](http://localhost:8000/docs).
112
+
113
+ ---
114
+
115
+ ### 2. Frontend Setup
116
+
117
+ 1. **Navigate to the directory**:
118
+ ```bash
119
+ cd frontend
120
+ ```
121
+
122
+ 2. **Configure environment variables**:
123
+ Ensure a `.env.local` file exists:
124
+ ```env
125
+ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
126
+ ```
127
+
128
+ 3. **Install dependencies**:
129
+ ```bash
130
+ npm install
131
+ ```
132
+
133
+ 4. **Start the development server**:
134
+ ```bash
135
+ npm run dev
136
+ ```
137
+ The dashboard will be running at [http://localhost:3000](http://localhost:3000).
138
+
139
+ ---
140
+
141
+ ### 3. Mobile Setup
142
+
143
+ 1. **Navigate to the directory**:
144
+ ```bash
145
+ cd mobile
146
+ ```
147
+
148
+ 2. **Get Flutter packages**:
149
+ ```bash
150
+ flutter pub get
151
+ ```
152
+
153
+ 3. **Run the application**:
154
+ Make sure you have an active emulator or connected device:
155
+ ```bash
156
+ flutter run
157
+ ```
158
+
159
+ ---
160
+
161
+ ## 🔒 Security & Verification Parameters
162
+ The verification strictness can be controlled globally via the administrator settings page or in `.env`:
163
+ * **Face Embedding matching threshold**: Standard threshold is configured to `0.75` (cosine similarity/confidence score).
164
+ * **Liveness Detection threshold**: Values above `0.5` denote real face image inputs.
165
+ * **Geofencing validation**: Distance calculated dynamically using the Haversine formula based on student's GPS reports and active class geofence boundaries.
backend/.env.example ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Settings
2
+ PROJECT_NAME="Smart Attendance System API"
3
+ API_V1_STR="/api/v1"
4
+
5
+ # Database Configuration
6
+ # Replace with actual PostgreSQL connection credentials
7
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/smart_attendance?schema=public"
8
+
9
+ # JWT Security
10
+ # Generate a secure secret using: openssl rand -hex 32
11
+ JWT_SECRET="supersecretkeychangeinproduction"
12
+ JWT_ALGORITHM="HS256"
13
+ ACCESS_TOKEN_EXPIRE_MINUTES=1440
14
+
15
+ # Redis Cache
16
+ REDIS_URL="redis://localhost:6379/0"
17
+
18
+ # AI Thresholds
19
+ PASS_THRESHOLD=0.75
20
+
21
+ # Environment (development / production)
22
+ ENVIRONMENT="development"
23
+
backend/.gitignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .flakes8
14
+ .installed.cfg
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+
25
+ # Virtual Environments
26
+ .venv/
27
+ venv/
28
+ ENV/
29
+
30
+ # Environment variables
31
+ .env
32
+ .env.*
33
+ !.env.example
34
+
35
+ # Firebase & Secrets
36
+ firebase-credentials.json
37
+ google-services.json
38
+
39
+ # Prisma
40
+ prisma/client/
41
+ *.db
42
+ *.sqlite3
43
+
44
+ # Logs
45
+ logs/
46
+ *.log
47
+
48
+ # Project specific
49
+ static/
50
+ uploads/
51
+ !static/.gitkeep
52
+ !uploads/.gitkeep
53
+
54
+ # AI/ML
55
+ *.h5
56
+ *.pb
57
+ *.pt
58
+ *.pth
59
+ *.pkl
60
+ *.joblib
61
+ *.onnx
62
+
63
+ # Pytest
64
+ .pytest_cache/
65
+ .coverage
66
+ htmlcov/
backend/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
backend/app/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+
3
+ try:
4
+ import tf_keras
5
+ import tensorflow as tf
6
+ import keras._tf_keras.keras.layers as compatibility_layers
7
+
8
+ compatibility_layers.LocallyConnected2D = tf_keras.layers.LocallyConnected2D
9
+ sys.modules["tensorflow.keras.layers.LocallyConnected2D"] = tf_keras.layers.LocallyConnected2D
10
+ tf.keras.layers.LocallyConnected2D = tf_keras.layers.LocallyConnected2D
11
+ except Exception:
12
+ pass
13
+
backend/app/api/admin.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import wraps
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
4
+ from prisma.models import User
5
+
6
+ from app.api.dependencies import RoleChecker, get_current_user
7
+ from app.db.client import db
8
+ from app.repositories.attendance_repo import AttendanceRepository
9
+ from app.schemas.student import StudentCreate, StudentResponse, StudentUpdate
10
+ from app.schemas.teacher import TeacherCreate, TeacherResponse, TeacherUpdate
11
+ from app.schemas.admin import (
12
+ ClassCreate, ClassUpdate, ClassResponse, AssignTeacherRequest, EnrollRequest,
13
+ DepartmentCreate, DepartmentUpdate, DepartmentResponse,
14
+ AuditLogResponse, AdminStatsResponse, AdminResetPasswordRequest,
15
+ )
16
+ from app.schemas.master_data import (
17
+ SubjectCreate, SubjectUpdate, SubjectResponse,
18
+ ClassroomCreate, ClassroomUpdate, ClassroomResponse,
19
+ DesignationCreate, DesignationUpdate, DesignationResponse,
20
+ )
21
+ from app.services.admin_service import AdminService
22
+ from app.services.absentee_scanner import run_absentee_scan
23
+
24
+ admin_protection = Depends(RoleChecker(allowed_roles=["ADMIN"]))
25
+ router = APIRouter(prefix="/admin", tags=["Admin System Operations"], dependencies=[admin_protection])
26
+
27
+
28
+ def _get_client_ip(request: Request) -> str:
29
+ forwarded = request.headers.get("X-Forwarded-For")
30
+ if forwarded:
31
+ return forwarded.split(",")[0].strip()
32
+ return request.client.host if request.client else "unknown"
33
+
34
+
35
+ def _handle_value_err(func):
36
+ @wraps(func)
37
+ async def wrapper(*args, **kwargs):
38
+ try:
39
+ return await func(*args, **kwargs)
40
+ except ValueError as err:
41
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(err))
42
+ except Exception as err:
43
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
44
+ return wrapper
45
+
46
+
47
+ def _handle_generic_err(func):
48
+ @wraps(func)
49
+ async def wrapper(*args, **kwargs):
50
+ try:
51
+ return await func(*args, **kwargs)
52
+ except Exception as err:
53
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
54
+ return wrapper
55
+
56
+
57
+ @router.post("/users/student", response_model=StudentResponse, status_code=status.HTTP_201_CREATED)
58
+ @_handle_generic_err
59
+ async def create_student(data: StudentCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
60
+ return await admin_service.create_student(data, actor=current_user.email, ip=_get_client_ip(request))
61
+
62
+
63
+ @router.post("/users/teacher", response_model=TeacherResponse, status_code=status.HTTP_201_CREATED)
64
+ @_handle_generic_err
65
+ async def create_teacher(data: TeacherCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
66
+ return await admin_service.create_teacher(data, actor=current_user.email, ip=_get_client_ip(request))
67
+
68
+
69
+ @router.post("/classes", response_model=ClassResponse, status_code=status.HTTP_201_CREATED)
70
+ @_handle_value_err
71
+ async def create_class(data: ClassCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
72
+ return await admin_service.create_class(data, actor=current_user.email, ip=_get_client_ip(request))
73
+
74
+
75
+ @router.put("/classes/{class_id}/assign-teacher", response_model=ClassResponse)
76
+ @_handle_value_err
77
+ async def assign_teacher(class_id: str, data: AssignTeacherRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
78
+ return await admin_service.assign_teacher(class_id=class_id, teacher_id=data.teacher_id, actor=current_user.email, ip=_get_client_ip(request))
79
+
80
+
81
+ @router.post("/classes/{class_id}/enroll", status_code=status.HTTP_200_OK)
82
+ @_handle_value_err
83
+ async def enroll_students(class_id: str, data: EnrollRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()) -> dict:
84
+ enrolled_count = await admin_service.enroll_students(class_id=class_id, student_ids=data.student_ids, actor=current_user.email, ip=_get_client_ip(request))
85
+ return {"status": "success", "enrolled_count": enrolled_count}
86
+
87
+
88
+ @router.get("/users/students", response_model=list[StudentResponse])
89
+ async def get_students(admin_service: AdminService = Depends()):
90
+ return await admin_service.get_all_students()
91
+
92
+
93
+ @router.get("/users/students/{id}", response_model=StudentResponse)
94
+ async def get_student_by_id(id: str, admin_service: AdminService = Depends()):
95
+ student = await db.student.find_unique(where={"id": id}, include={"user": True, "department": True})
96
+ if not student:
97
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
98
+ return StudentResponse(
99
+ id=student.id, user_id=student.userId, enrollment_number=student.enrollmentNumber,
100
+ email=student.user.email if student.user else "", first_name=student.firstName,
101
+ last_name=student.lastName, phone=student.phone, gender=student.gender,
102
+ date_of_birth=student.dateOfBirth, department_id=student.departmentId,
103
+ department_name=student.department.name if student.department else None,
104
+ semester=student.semester, batch=student.batch,
105
+ )
106
+
107
+
108
+ @router.put("/users/students/{id}", response_model=StudentResponse)
109
+ @_handle_generic_err
110
+ async def update_student(id: str, data: StudentUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
111
+ return await admin_service.update_student(id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request))
112
+
113
+
114
+
115
+ @router.get("/users/teachers", response_model=list[TeacherResponse])
116
+ async def get_teachers(admin_service: AdminService = Depends()):
117
+ return await admin_service.get_all_teachers()
118
+
119
+
120
+ @router.get("/users/teachers/{id}", response_model=TeacherResponse)
121
+ async def get_teacher_by_id(id: str):
122
+ teacher = await db.teacher.find_unique(where={"id": id}, include={"user": True, "department": True, "designation": True})
123
+ if not teacher:
124
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Teacher not found")
125
+ return TeacherResponse(
126
+ id=teacher.id, user_id=teacher.userId, email=teacher.user.email if teacher.user else "",
127
+ employee_id=teacher.employeeId, first_name=teacher.firstName, last_name=teacher.lastName,
128
+ department_id=teacher.departmentId, designation_id=teacher.designationId,
129
+ department=teacher.department.name if teacher.department else "",
130
+ designation=teacher.designation.name if teacher.designation else "",
131
+ phone=teacher.phone, qualification=teacher.qualification, specialization=teacher.specialization,
132
+ experience_years=teacher.experienceYears, joining_date=teacher.joiningDate,
133
+ )
134
+
135
+
136
+ @router.put("/users/teachers/{id}", response_model=TeacherResponse)
137
+ @_handle_generic_err
138
+ async def update_teacher(id: str, data: TeacherUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
139
+ return await admin_service.update_teacher(id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request))
140
+
141
+
142
+ @router.put("/users/{user_id}/reset-password")
143
+ async def reset_user_password(user_id: str, data: AdminResetPasswordRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
144
+ try:
145
+ await admin_service.reset_user_password(user_id, data.new_password, actor=current_user.email, ip=_get_client_ip(request))
146
+ return {"status": "success", "message": "Password updated successfully"}
147
+ except ValueError as err:
148
+ raise HTTPException(status_code=404, detail=str(err))
149
+ except Exception as err:
150
+ raise HTTPException(status_code=400, detail=str(err))
151
+
152
+
153
+ @router.get("/classes", response_model=list[ClassResponse])
154
+ async def get_classes(admin_service: AdminService = Depends()):
155
+ return await admin_service.get_all_classes()
156
+
157
+
158
+ @router.get("/classes/{class_id}", response_model=ClassResponse)
159
+ async def get_class_by_id(class_id: str, admin_service: AdminService = Depends()):
160
+ cls = await db.academicclass.find_unique(where={"id": class_id}, include={"subject": True, "classroom": True, "enrollments": True})
161
+ if not cls:
162
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Class not found")
163
+ return ClassResponse(
164
+ id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "",
165
+ subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId,
166
+ classroom_name=cls.classroom.name if cls.classroom else None,
167
+ semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents,
168
+ enrolled_count=len(cls.enrollments) if cls.enrollments else 0,
169
+ enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [],
170
+ )
171
+
172
+
173
+ @router.put("/classes/{class_id}", response_model=ClassResponse)
174
+ @_handle_generic_err
175
+ async def update_class(class_id: str, data: ClassUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
176
+ return await admin_service.update_class(class_id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request))
177
+
178
+
179
+ # --- Departments ---
180
+ @router.get("/departments", response_model=list[DepartmentResponse])
181
+ async def get_departments(admin_service: AdminService = Depends()):
182
+ return await admin_service.get_all_departments()
183
+
184
+
185
+ @router.get("/departments/{id}", response_model=DepartmentResponse)
186
+ async def get_department(id: str, admin_service: AdminService = Depends()):
187
+ dept = await admin_service.get_department_by_id(id)
188
+ if not dept:
189
+ raise HTTPException(status_code=404, detail="Department not found")
190
+ return dept
191
+
192
+
193
+ @router.post("/departments", response_model=DepartmentResponse)
194
+ @_handle_generic_err
195
+ async def create_department(data: DepartmentCreate, admin_service: AdminService = Depends()):
196
+ return await admin_service.create_department(data.name, data.code, data.head, data.description)
197
+
198
+
199
+ @router.put("/departments/{id}", response_model=DepartmentResponse)
200
+ @_handle_generic_err
201
+ async def update_department(id: str, data: DepartmentUpdate, admin_service: AdminService = Depends()):
202
+ return await admin_service.update_department(id, data.model_dump(exclude_unset=True))
203
+
204
+
205
+ @router.delete("/departments/{id}")
206
+ async def delete_department(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
207
+ try:
208
+ await admin_service.delete_department(id, actor=current_user.email, ip=_get_client_ip(request))
209
+ return {"status": "success"}
210
+ except ValueError as err:
211
+ raise HTTPException(status_code=400, detail=str(err))
212
+ except Exception:
213
+ raise HTTPException(status_code=500, detail="Internal server error")
214
+
215
+
216
+ # --- Subjects ---
217
+ @router.get("/subjects", response_model=list[SubjectResponse])
218
+ async def get_subjects(admin_service: AdminService = Depends()):
219
+ return await admin_service.get_all_subjects()
220
+
221
+
222
+ @router.get("/subjects/{id}", response_model=SubjectResponse)
223
+ async def get_subject(id: str, admin_service: AdminService = Depends()):
224
+ sub = await admin_service.get_subject_by_id(id)
225
+ if not sub:
226
+ raise HTTPException(status_code=404, detail="Subject not found")
227
+ return sub
228
+
229
+
230
+ @router.post("/subjects", response_model=SubjectResponse)
231
+ @_handle_generic_err
232
+ async def create_subject(data: SubjectCreate, admin_service: AdminService = Depends()):
233
+ return await admin_service.create_subject(data.name, data.code, data.description)
234
+
235
+
236
+ @router.put("/subjects/{id}", response_model=SubjectResponse)
237
+ @_handle_generic_err
238
+ async def update_subject(id: str, data: SubjectUpdate, admin_service: AdminService = Depends()):
239
+ return await admin_service.update_subject(id, data.model_dump(exclude_unset=True))
240
+
241
+
242
+ @router.delete("/subjects/{id}")
243
+ async def delete_subject(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
244
+ try:
245
+ await admin_service.delete_subject(id, actor=current_user.email, ip=_get_client_ip(request))
246
+ return {"status": "success"}
247
+ except ValueError as err:
248
+ raise HTTPException(status_code=400, detail=str(err))
249
+ except Exception:
250
+ raise HTTPException(status_code=500, detail="Internal server error")
251
+
252
+
253
+ # --- Classrooms ---
254
+ @router.get("/classrooms", response_model=list[ClassroomResponse])
255
+ async def get_classrooms(admin_service: AdminService = Depends()):
256
+ return await admin_service.get_all_classrooms()
257
+
258
+
259
+ @router.get("/classrooms/{id}", response_model=ClassroomResponse)
260
+ async def get_classroom(id: str, admin_service: AdminService = Depends()):
261
+ classroom = await admin_service.get_classroom_by_id(id)
262
+ if not classroom:
263
+ raise HTTPException(status_code=404, detail="Classroom not found")
264
+ return classroom
265
+
266
+
267
+ @router.post("/classrooms", response_model=ClassroomResponse)
268
+ @_handle_generic_err
269
+ async def create_classroom(data: ClassroomCreate, admin_service: AdminService = Depends()):
270
+ return await admin_service.create_classroom(data.name, data.building, data.capacity)
271
+
272
+
273
+ @router.put("/classrooms/{id}", response_model=ClassroomResponse)
274
+ @_handle_generic_err
275
+ async def update_classroom(id: str, data: ClassroomUpdate, admin_service: AdminService = Depends()):
276
+ return await admin_service.update_classroom(id, data.model_dump(exclude_unset=True))
277
+
278
+
279
+ @router.delete("/classrooms/{id}")
280
+ async def delete_classroom(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
281
+ try:
282
+ await admin_service.delete_classroom(id, actor=current_user.email, ip=_get_client_ip(request))
283
+ return {"status": "success"}
284
+ except ValueError as err:
285
+ raise HTTPException(status_code=400, detail=str(err))
286
+ except Exception:
287
+ raise HTTPException(status_code=500, detail="Internal server error")
288
+
289
+
290
+ # --- Designations ---
291
+ @router.get("/designations", response_model=list[DesignationResponse])
292
+ async def get_designations(admin_service: AdminService = Depends()):
293
+ return await admin_service.get_all_designations()
294
+
295
+
296
+ @router.get("/designations/{id}", response_model=DesignationResponse)
297
+ async def get_designation(id: str, admin_service: AdminService = Depends()):
298
+ desig = await admin_service.get_designation_by_id(id)
299
+ if not desig:
300
+ raise HTTPException(status_code=404, detail="Designation not found")
301
+ return desig
302
+
303
+
304
+ @router.post("/designations", response_model=DesignationResponse)
305
+ @_handle_generic_err
306
+ async def create_designation(data: DesignationCreate, admin_service: AdminService = Depends()):
307
+ return await admin_service.create_designation(data.name, data.code, data.description)
308
+
309
+
310
+ @router.put("/designations/{id}", response_model=DesignationResponse)
311
+ @_handle_generic_err
312
+ async def update_designation(id: str, data: DesignationUpdate, admin_service: AdminService = Depends()):
313
+ return await admin_service.update_designation(id, data.model_dump(exclude_unset=True))
314
+
315
+
316
+ @router.delete("/designations/{id}")
317
+ async def delete_designation(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()):
318
+ try:
319
+ await admin_service.delete_designation(id, actor=current_user.email, ip=_get_client_ip(request))
320
+ return {"status": "success"}
321
+ except ValueError as err:
322
+ raise HTTPException(status_code=400, detail=str(err))
323
+ except Exception:
324
+ raise HTTPException(status_code=500, detail="Internal server error")
325
+
326
+
327
+ # --- Misc Admin ---
328
+ @router.get("/audit", response_model=list[AuditLogResponse])
329
+ async def get_audit_logs(admin_service: AdminService = Depends()):
330
+ return await admin_service.get_audit_logs()
331
+
332
+
333
+ @router.get("/stats", response_model=AdminStatsResponse)
334
+ async def get_admin_stats(admin_service: AdminService = Depends()):
335
+ return await admin_service.get_stats()
336
+
337
+
338
+ @router.post("/scan-absentees", status_code=status.HTTP_200_OK)
339
+ async def scan_absentee_anomalies(
340
+ contamination: float = 0.10,
341
+ attendance_repo: AttendanceRepository = Depends(),
342
+ ) -> list[dict]:
343
+ records = await attendance_repo.get_all_absences()
344
+ if not records or len(records) < 5:
345
+ return []
346
+
347
+ student_map = {}
348
+ for r in records:
349
+ if r.student:
350
+ first_name = r.student.firstName or ""
351
+ last_name = r.student.lastName or ""
352
+ full_name = f"{first_name} {last_name}".strip() or "Unknown Student"
353
+ student_map[r.studentId] = {
354
+ "student_name": full_name,
355
+ "enrollment_number": r.student.enrollmentNumber,
356
+ }
357
+
358
+ rows = [{"student_id": r.studentId, "status": r.status, "day_of_week": r.createdAt.strftime("%A")} for r in records]
359
+ try:
360
+ flagged = await run_absentee_scan(attendance_records=rows, contamination=contamination)
361
+ for item in flagged:
362
+ s_id = item.get("student_id")
363
+ s_info = student_map.get(s_id, {})
364
+ item["student_name"] = s_info.get("student_name", "Unknown Student")
365
+ item["enrollment_number"] = s_info.get("enrollment_number", "N/A")
366
+ return flagged
367
+ except Exception as err:
368
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Outlier pattern extraction failed: {str(err)}")
369
+
370
+
371
+ # --- System Config ---
372
+ from app.schemas.system_config import SystemConfigResponse, SystemConfigUpdate
373
+ from app.services.system_config_service import SystemConfigService
374
+
375
+ @router.get("/config", response_model=SystemConfigResponse)
376
+ async def get_system_config(config_service: SystemConfigService = Depends()):
377
+ return await config_service.get_config()
378
+
379
+
380
+ @router.patch("/config", response_model=SystemConfigResponse)
381
+ async def update_system_config(data: SystemConfigUpdate, config_service: SystemConfigService = Depends()):
382
+ return await config_service.update_config(
383
+ is_face_recognition_enabled=data.is_face_recognition_enabled,
384
+ is_gps_verification_enabled=data.is_gps_verification_enabled,
385
+ is_ai_background_validation_enabled=data.is_ai_background_validation_enabled,
386
+ )
387
+
388
+
backend/app/api/auth.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
4
+ from prisma.models import User
5
+
6
+ from app.api.dependencies import get_current_user, reusable_oauth2
7
+ from app.core.config import settings
8
+ from app.core.logging_config import get_logger
9
+ from app.core.security import decode_access_token
10
+ from app.db.client import db
11
+ from app.db.redis import get_redis
12
+ from app.repositories.student_repo import StudentRepository
13
+ from app.schemas.auth import Token, UserLogin, UserProfileResponse, DeviceChangeRequestCreate
14
+ from app.schemas.student import StudentCreate, StudentResponse
15
+ from app.schemas.teacher import TeacherCreate, TeacherResponse
16
+ from app.services.auth_service import AuthService
17
+ from app.services.device_change_service import DeviceChangeService
18
+
19
+ logger = get_logger("app.api.auth")
20
+
21
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
22
+
23
+ _RATE_LIMIT_WINDOW = 60
24
+ _RATE_LIMIT_MAX = 10
25
+
26
+
27
+ async def _rate_limit(request: Request) -> None:
28
+ if settings.ENVIRONMENT == "development":
29
+ return
30
+ forwarded = request.headers.get("X-Forwarded-For")
31
+ ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown")
32
+ key = f"ratelimit:auth:{ip}"
33
+ r = await get_redis()
34
+ count = await r.incr(key)
35
+ if count == 1:
36
+ await r.expire(key, _RATE_LIMIT_WINDOW)
37
+ if count > _RATE_LIMIT_MAX:
38
+ raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests. Please try again later.")
39
+
40
+
41
+ @router.post("/login", response_model=Token)
42
+ async def login(login_data: UserLogin, request: Request, auth_service: AuthService = Depends()) -> Token:
43
+ await _rate_limit(request)
44
+ token = await auth_service.authenticate(login_data)
45
+ if not token:
46
+ logger.warning("Failed login: %s", login_data.email)
47
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password")
48
+ return token
49
+
50
+
51
+ @router.post("/register/student", response_model=StudentResponse, status_code=status.HTTP_201_CREATED)
52
+ async def register_student(data: StudentCreate, request: Request, auth_service: AuthService = Depends()) -> StudentResponse:
53
+ await _rate_limit(request)
54
+ student = await auth_service.register_student(data)
55
+ if not student:
56
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email is already registered")
57
+ return student
58
+
59
+
60
+ @router.post("/register/teacher", response_model=TeacherResponse, status_code=status.HTTP_201_CREATED)
61
+ async def register_teacher(data: TeacherCreate, request: Request, auth_service: AuthService = Depends()) -> TeacherResponse:
62
+ await _rate_limit(request)
63
+ teacher = await auth_service.register_teacher(data)
64
+ if not teacher:
65
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email is already registered")
66
+ return teacher
67
+
68
+
69
+ @router.get("/me", response_model=UserProfileResponse)
70
+ async def get_me(current_user: User = Depends(get_current_user)) -> UserProfileResponse:
71
+ student_profile = None
72
+ teacher_profile = None
73
+
74
+ if current_user.role == "STUDENT":
75
+ student = await db.student.find_unique(where={"userId": current_user.id})
76
+ if student:
77
+ embedding = await StudentRepository().get_face_embedding(student.id)
78
+ student_profile = {
79
+ "id": student.id,
80
+ "enrollment_number": student.enrollmentNumber,
81
+ "first_name": student.firstName,
82
+ "last_name": student.lastName,
83
+ "face_registered": embedding is not None and len(embedding) > 0,
84
+ }
85
+ elif current_user.role == "TEACHER":
86
+ teacher = await db.teacher.find_unique(where={"userId": current_user.id})
87
+ if teacher:
88
+ teacher_profile = {
89
+ "id": teacher.id,
90
+ "department": teacher.department.name if teacher.department else "",
91
+ "designation": teacher.designation.name if teacher.designation else "",
92
+ "employee_id": teacher.employeeId,
93
+ "first_name": teacher.firstName,
94
+ "last_name": teacher.lastName,
95
+ }
96
+
97
+ return UserProfileResponse(
98
+ id=current_user.id,
99
+ email=current_user.email,
100
+ role=current_user.role,
101
+ is_active=current_user.isActive,
102
+ student_profile=student_profile,
103
+ teacher_profile=teacher_profile,
104
+ )
105
+
106
+
107
+
108
+ @router.post("/logout", status_code=status.HTTP_200_OK)
109
+ async def logout(token: str = Depends(reusable_oauth2)) -> dict:
110
+ payload = decode_access_token(token)
111
+ if payload:
112
+ exp = payload.get("exp")
113
+ if exp:
114
+ ttl = exp - int(datetime.now(timezone.utc).timestamp())
115
+ if ttl > 0:
116
+ try:
117
+ await get_redis().setex(f"denylist:{token}", ttl, "revoked")
118
+ logger.info("Token revoked: user=%s", payload.get("sub"))
119
+ except Exception as cache_err:
120
+ logger.warning("Failed to add token to Redis denylist: %s", cache_err)
121
+ return {"status": "success", "message": "Successfully logged out."}
122
+ @router.post("/request-device-change", status_code=status.HTTP_200_OK)
123
+ async def request_device_change(
124
+ data: DeviceChangeRequestCreate,
125
+ request: Request,
126
+ device_change_service: DeviceChangeService = Depends(),
127
+ ) -> dict:
128
+ await _rate_limit(request)
129
+ await device_change_service.request_device_change(data)
130
+ return {"status": "success", "message": "Device change request submitted successfully."}
131
+
132
+
133
+ # --- System Config (Public) ---
134
+ from app.schemas.system_config import SystemConfigResponse
135
+ from app.services.system_config_service import SystemConfigService
136
+
137
+ @router.get("/config", response_model=SystemConfigResponse)
138
+ async def get_public_system_config(config_service: SystemConfigService = Depends()):
139
+ return await config_service.get_config()
140
+
backend/app/api/dependencies.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer
3
+ from prisma.models import User, Student, Teacher
4
+
5
+ from app.core.logging_config import get_logger
6
+ from app.core.security import decode_access_token
7
+ from app.repositories.user_repo import UserRepository
8
+ from app.repositories.student_repo import StudentRepository
9
+ from app.repositories.teacher_repo import TeacherRepository
10
+
11
+ logger = get_logger("app.auth")
12
+
13
+ reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
14
+
15
+ _UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
16
+ _FORBIDDEN = {"STUDENT": "Access forbidden: Students only", "TEACHER": "Access forbidden: Teachers only"}
17
+ _INACTIVE = "User account is inactive or disabled"
18
+
19
+
20
+ async def _check_token_revoked(token: str) -> None:
21
+ try:
22
+ from app.db.redis import get_redis
23
+ if await get_redis().get(f"denylist:{token}"):
24
+ raise HTTPException(
25
+ status_code=status.HTTP_401_UNAUTHORIZED,
26
+ detail="Token has been revoked",
27
+ headers={"WWW-Authenticate": "Bearer"},
28
+ )
29
+ except HTTPException:
30
+ raise
31
+ except Exception:
32
+ pass
33
+
34
+
35
+ async def _validate_token_payload(token: str) -> dict:
36
+ await _check_token_revoked(token)
37
+ payload = decode_access_token(token)
38
+ if not payload:
39
+ raise HTTPException(
40
+ status_code=status.HTTP_401_UNAUTHORIZED,
41
+ detail="Could not validate credentials",
42
+ headers={"WWW-Authenticate": "Bearer"},
43
+ )
44
+ user_id = payload.get("sub")
45
+ if not user_id:
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED,
48
+ detail="Subject not found in token",
49
+ )
50
+ return payload
51
+
52
+
53
+ async def get_current_user(token: str = Depends(reusable_oauth2)) -> User:
54
+ payload = await _validate_token_payload(token)
55
+ user_id = payload.get("sub")
56
+ user = await UserRepository().get_by_id(user_id)
57
+ if not user or not user.isActive:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_401_UNAUTHORIZED,
60
+ detail=_INACTIVE,
61
+ )
62
+ return user
63
+
64
+
65
+ async def get_current_student(current_user: User = Depends(get_current_user)) -> Student:
66
+ if current_user.role != "STUDENT":
67
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_FORBIDDEN["STUDENT"])
68
+ student = await StudentRepository().get_by_user_id(current_user.id)
69
+ if not student:
70
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student profile not found")
71
+ return student
72
+
73
+
74
+ async def get_current_teacher(current_user: User = Depends(get_current_user)) -> Teacher:
75
+ if current_user.role != "TEACHER":
76
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_FORBIDDEN["TEACHER"])
77
+ teacher = await TeacherRepository().get_by_user_id(current_user.id)
78
+ if not teacher:
79
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Teacher profile not found")
80
+ return teacher
81
+
82
+
83
+ class RoleChecker:
84
+ def __init__(self, allowed_roles: list[str]) -> None:
85
+ self.allowed_roles = allowed_roles
86
+
87
+ def __call__(self, current_user: User = Depends(get_current_user)) -> User:
88
+ if current_user.role not in self.allowed_roles:
89
+ raise HTTPException(
90
+ status_code=status.HTTP_403_FORBIDDEN,
91
+ detail="Access denied: Insufficient permissions",
92
+ )
93
+ return current_user
94
+
95
+
96
+ async def get_current_user_from_token(token: str) -> User:
97
+ await _check_token_revoked(token)
98
+ payload = decode_access_token(token)
99
+ if not payload:
100
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials")
101
+ user_id = payload.get("sub")
102
+ if not user_id:
103
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Subject not found in token")
104
+ user = await UserRepository().get_by_id(user_id)
105
+ if not user or not user.isActive:
106
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INACTIVE)
107
+ return user
108
+
backend/app/api/logs.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import APIRouter, status
4
+
5
+ from app.core.logging_config import get_logger
6
+ from app.schemas.log import LogEvent
7
+
8
+ router = APIRouter(prefix="/logs", tags=["Logging"])
9
+
10
+ logger = get_logger("app.client")
11
+
12
+
13
+ @router.post("", status_code=status.HTTP_200_OK)
14
+ async def ingest_log(log_event: LogEvent) -> dict:
15
+ logger.log(
16
+ getattr(logging, log_event.level.upper(), logging.INFO),
17
+ "[%s] [%s] %s",
18
+ log_event.source, log_event.timestamp, log_event.message,
19
+ )
20
+ return {"status": "success"}
backend/app/api/student.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import shutil
4
+ from datetime import datetime, timezone, date, timedelta
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
7
+ from prisma.models import Student
8
+
9
+ from app.api.dependencies import get_current_student
10
+ from app.core.config import settings
11
+ from app.core.logging_config import get_logger
12
+ from app.core.security import create_access_token
13
+ from app.db.client import db
14
+ from app.repositories.leave_repo import LeaveRepository
15
+
16
+ from app.schemas.attendance import AttendanceMarkResponse, AttendanceAnalyzeResponse
17
+ from app.schemas.student import StudentAttendanceHistoryResponse, StudentClassResponse
18
+ from app.schemas.leave import LeaveRequestResponse, LeaveRequestListResponse
19
+ from app.services.attendance_service import AttendanceService, AttendanceSubmission
20
+ from app.services.student_service import StudentService
21
+ from app.services.gamification_service import GamificationService
22
+
23
+ logger = get_logger("app.api.student")
24
+
25
+ router = APIRouter(prefix="/student", tags=["Student Features"])
26
+
27
+ _MAX_IMAGE_SIZE = 5 * 1024 * 1024
28
+ _VALID_IMAGE_TYPES = {"image/jpeg", "image/png", "image/jpg"}
29
+
30
+
31
+ def _save_uploaded_image(upload_file: UploadFile, folder: str) -> str:
32
+ target_dir = os.path.join(settings.UPLOAD_DIR, folder)
33
+ os.makedirs(target_dir, exist_ok=True)
34
+ ext = os.path.splitext(upload_file.filename or "")[1] or ".jpg"
35
+ target_path = os.path.join(target_dir, f"{uuid.uuid4()}{ext}")
36
+ with open(target_path, "wb") as buffer:
37
+ shutil.copyfileobj(upload_file.file, buffer)
38
+ return target_path
39
+
40
+
41
+
42
+ def _validate_image(image: UploadFile) -> None:
43
+ if image.content_type not in _VALID_IMAGE_TYPES:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
46
+ detail="Unsupported media type. Upload must be a valid JPEG or PNG image.",
47
+ )
48
+ if image.size is not None and image.size > _MAX_IMAGE_SIZE:
49
+ raise HTTPException(
50
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
51
+ detail="Payload too large. Uploaded image cannot exceed 5MB.",
52
+ )
53
+
54
+
55
+ def _make_leave_response(leave, student_name: str, enrollment_number: str = None) -> LeaveRequestResponse:
56
+ return LeaveRequestResponse(
57
+ id=leave.id,
58
+ student_id=leave.studentId,
59
+ student_name=student_name or "Unknown",
60
+ enrollment_number=enrollment_number or (leave.student.enrollmentNumber if leave.student else "N/A"),
61
+ start_date=leave.startDate,
62
+ end_date=leave.endDate,
63
+ reason=leave.reason,
64
+ document_url=leave.documentUrl,
65
+ status=leave.status,
66
+ approved_by=leave.approvedBy,
67
+ approver_note=leave.approverNote,
68
+ created_at=leave.createdAt,
69
+ updated_at=leave.updatedAt,
70
+ )
71
+
72
+
73
+ def _student_name(student: Student) -> str:
74
+ if not student:
75
+ return "Unknown"
76
+ return f"{student.firstName or ''} {student.lastName or ''}".strip()
77
+
78
+
79
+ @router.post("/attendance/mark", response_model=AttendanceMarkResponse)
80
+ async def mark_attendance(
81
+ session_id: str = Form(...),
82
+ latitude: float = Form(...),
83
+ longitude: float = Form(...),
84
+ accuracy: float = Form(...),
85
+ image: UploadFile | None = File(None),
86
+ student: Student = Depends(get_current_student),
87
+ attendance_service: AttendanceService = Depends(),
88
+ ) -> AttendanceMarkResponse:
89
+ image_path = None
90
+ if image:
91
+ _validate_image(image)
92
+ image_path = _save_uploaded_image(image, "attendance")
93
+ submission = AttendanceSubmission(
94
+ student_id=student.id, session_id=session_id,
95
+ latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path,
96
+ )
97
+ try:
98
+ attendance = await attendance_service.mark_attendance(submission)
99
+ return AttendanceMarkResponse.model_validate(attendance)
100
+ except ValueError as err:
101
+ if image_path and os.path.exists(image_path):
102
+ os.remove(image_path)
103
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
104
+
105
+
106
+ @router.post("/attendance/analyze", response_model=AttendanceAnalyzeResponse)
107
+ async def analyze_attendance(
108
+ session_id: str = Form(...),
109
+ latitude: float = Form(...),
110
+ longitude: float = Form(...),
111
+ accuracy: float = Form(...),
112
+ image: UploadFile | None = File(None),
113
+ student: Student = Depends(get_current_student),
114
+ attendance_service: AttendanceService = Depends(),
115
+ ) -> AttendanceAnalyzeResponse:
116
+ """Run AI scoring and validation without saving the attendance record.
117
+
118
+ Returns scores and a short-lived review_token (5 min) that the student
119
+ can use to confirm submission via POST /attendance/confirm.
120
+ """
121
+ image_path = None
122
+ if image:
123
+ _validate_image(image)
124
+ image_path = _save_uploaded_image(image, "attendance")
125
+ submission = AttendanceSubmission(
126
+ student_id=student.id, session_id=session_id,
127
+ latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path,
128
+ )
129
+ try:
130
+ result = await attendance_service.analyze_attendance(submission)
131
+ return AttendanceAnalyzeResponse(**result)
132
+ except ValueError as err:
133
+ if image_path and os.path.exists(image_path):
134
+ os.remove(image_path)
135
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
136
+
137
+
138
+ @router.post("/attendance/confirm", response_model=AttendanceMarkResponse)
139
+ async def confirm_attendance(
140
+ review_token: str = Form(...),
141
+ student: Student = Depends(get_current_student),
142
+ attendance_service: AttendanceService = Depends(),
143
+ ) -> AttendanceMarkResponse:
144
+ """Confirm a previously analyzed attendance submission.
145
+
146
+ Accepts the review_token returned by POST /attendance/analyze and saves
147
+ the attendance record without re-running AI inference.
148
+ """
149
+ try:
150
+ attendance = await attendance_service.confirm_attendance(student.id, review_token)
151
+ return AttendanceMarkResponse.model_validate(attendance)
152
+ except ValueError as err:
153
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
154
+
155
+
156
+ @router.post("/register-face", status_code=status.HTTP_200_OK)
157
+ async def register_face(
158
+ image: UploadFile = File(...),
159
+ student: Student = Depends(get_current_student),
160
+ attendance_service: AttendanceService = Depends(),
161
+ ) -> dict:
162
+ _validate_image(image)
163
+ image_path = _save_uploaded_image(image, "registration")
164
+ try:
165
+ success = await attendance_service.register_face(student.id, image_path)
166
+ if not success:
167
+ raise ValueError("Could not extract a valid face from the image.")
168
+ return {"status": "success", "message": "Face embedding registered successfully."}
169
+ except ValueError as err:
170
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err))
171
+ finally:
172
+ if os.path.exists(image_path):
173
+ os.remove(image_path)
174
+
175
+
176
+ @router.get("/my-attendance", response_model=StudentAttendanceHistoryResponse)
177
+ async def get_my_attendance(
178
+ student: Student = Depends(get_current_student),
179
+ student_service: StudentService = Depends(),
180
+ ) -> StudentAttendanceHistoryResponse:
181
+ return await student_service.get_student_attendance_history(student.userId)
182
+
183
+
184
+ @router.get("/classes", response_model=list[StudentClassResponse])
185
+ async def get_my_classes(
186
+ student: Student = Depends(get_current_student),
187
+ student_service: StudentService = Depends(),
188
+ ) -> list[StudentClassResponse]:
189
+ return await student_service.get_student_classes(student.userId)
190
+
191
+
192
+ @router.post("/fcm-token", status_code=status.HTTP_200_OK)
193
+ async def register_fcm_token(
194
+ payload: dict,
195
+ student: Student = Depends(get_current_student),
196
+ ) -> dict:
197
+ token = payload.get("token")
198
+ if not token:
199
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="FCM token is required.")
200
+ await db.student.update(where={"id": student.id}, data={"fcmToken": token})
201
+ return {"status": "success", "message": "FCM token registered."}
202
+
203
+
204
+ @router.post("/attendance/{attendance_id}/note", status_code=status.HTTP_200_OK)
205
+ async def submit_flagged_note(
206
+ attendance_id: str,
207
+ payload: dict,
208
+ student: Student = Depends(get_current_student),
209
+ ) -> dict:
210
+ note = payload.get("note", "").strip()
211
+ if not note:
212
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot be empty.")
213
+ if len(note) > 500:
214
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot exceed 500 characters.")
215
+ record = await db.attendance.find_unique(where={"id": attendance_id})
216
+ if not record or record.studentId != student.id:
217
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance record not found.")
218
+ if record.status != "Flagged":
219
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notes can only be added to flagged records.")
220
+ await db.attendance.update(where={"id": attendance_id}, data={"studentNote": note})
221
+ return {"status": "success", "message": "Note submitted successfully."}
222
+
223
+
224
+ @router.get("/leaves", response_model=LeaveRequestListResponse)
225
+ async def get_my_leaves(
226
+ student: Student = Depends(get_current_student),
227
+ leave_repo: LeaveRepository = Depends(),
228
+ ) -> LeaveRequestListResponse:
229
+ leaves = await leave_repo.get_by_student_id(student.id)
230
+ leave_responses = [_make_leave_response(req, _student_name(req.student)) for req in leaves]
231
+ return LeaveRequestListResponse(
232
+ leaves=leave_responses,
233
+ total=len(leave_responses),
234
+ pending=sum(1 for req in leaves if req.status == "PENDING"),
235
+ approved=sum(1 for req in leaves if req.status == "APPROVED"),
236
+ rejected=sum(1 for req in leaves if req.status == "REJECTED"),
237
+ )
238
+
239
+
240
+ @router.post("/leaves", response_model=LeaveRequestResponse, status_code=status.HTTP_201_CREATED)
241
+ async def create_leave_request(
242
+ start_date: str = Form(...),
243
+ end_date: str = Form(...),
244
+ reason: str = Form(...),
245
+ document: UploadFile = File(None),
246
+ student: Student = Depends(get_current_student),
247
+ leave_repo: LeaveRepository = Depends(),
248
+ ) -> LeaveRequestResponse:
249
+ try:
250
+ s_date = date.fromisoformat(start_date)
251
+ e_date = date.fromisoformat(end_date)
252
+ except ValueError:
253
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use YYYY-MM-DD.")
254
+ if e_date < s_date:
255
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="End date must be after or equal to start date.")
256
+ if len(reason) < 10 or len(reason) > 500:
257
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reason must be between 10 and 500 characters.")
258
+
259
+ document_url = None
260
+ if document and document.filename:
261
+ try:
262
+ upload_dir = "static/leaves"
263
+ os.makedirs(upload_dir, exist_ok=True)
264
+ ext = os.path.splitext(document.filename)[1]
265
+ filename = f"leave_{student.id}_{int(datetime.now().timestamp())}{ext}"
266
+ file_path = os.path.join(upload_dir, filename)
267
+ with open(file_path, "wb") as f:
268
+ f.write(await document.read())
269
+ document_url = f"/static/leaves/{filename}"
270
+ except Exception:
271
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not save leave document.")
272
+
273
+ leave = await leave_repo.create({
274
+ "studentId": student.id,
275
+ "startDate": datetime.combine(s_date, datetime.min.time()).replace(tzinfo=timezone.utc),
276
+ "endDate": datetime.combine(e_date, datetime.max.time()).replace(tzinfo=timezone.utc),
277
+ "reason": reason,
278
+ "documentUrl": document_url,
279
+ "status": "PENDING",
280
+ })
281
+ return _make_leave_response(leave, _student_name(student), student.enrollmentNumber)
282
+
283
+
284
+ @router.get("/smart-pass", response_model=dict)
285
+ async def get_smart_pass(student: Student = Depends(get_current_student)) -> dict:
286
+ qr_token = create_access_token(
287
+ subject=student.userId,
288
+ role="STUDENT",
289
+ expires_delta=timedelta(seconds=30),
290
+ extra_data={"student_id": student.id, "enrollment_number": student.enrollmentNumber, "type": "smart_pass"},
291
+ )
292
+ return {
293
+ "qr_token": qr_token,
294
+ "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(),
295
+ "student_name": _student_name(student),
296
+ "enrollment_number": student.enrollmentNumber,
297
+ }
298
+
299
+
300
+ @router.get("/stats", response_model=dict)
301
+ async def get_my_stats(student: Student = Depends(get_current_student)) -> dict:
302
+ return await GamificationService().get_student_stats(student.id)
303
+
304
+
305
+ @router.get("/leaderboard", response_model=dict)
306
+ async def get_leaderboard(student: Student = Depends(get_current_student)) -> dict:
307
+ return await GamificationService().get_leaderboard(student.id)
308
+
backend/app/api/teacher.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from prisma.models import Teacher
6
+
7
+ from app.api.dependencies import get_current_teacher
8
+ from app.db.client import db
9
+ from app.repositories.attendance_repo import AttendanceRepository
10
+ from app.repositories.leave_repo import LeaveRepository
11
+ from app.schemas.teacher import (
12
+ SessionResponse, SessionStart, GeofenceUpsert, GeofenceResponse,
13
+ AcademicClassWithGeofenceResponse, SessionAttendanceResponse,
14
+ ClassStatsResponse, AttendanceManualOverride, SessionWithClassResponse,
15
+ BulkMarkRequest, AbsentStudentItem, DeviceChangeResponse, DeviceChangeApprove
16
+ )
17
+ from app.schemas.attendance import AttendanceReview, FlaggedAttendanceResponse
18
+ from app.schemas.leave import LeaveRequestResponse, LeaveRequestApprove
19
+ from app.services.session_service import SessionService
20
+ from app.services.attendance_service import AttendanceService
21
+ from app.services.teacher_service import TeacherService
22
+ from app.services.leave_service import LeaveService
23
+ from app.services.device_change_service import DeviceChangeService
24
+
25
+ router = APIRouter(prefix="/teacher", tags=["Teacher Features"])
26
+
27
+
28
+ def _make_leave_response(leave) -> LeaveRequestResponse:
29
+ name = f"{leave.student.firstName or ''} {leave.student.lastName or ''}".strip()
30
+ return LeaveRequestResponse(
31
+ id=leave.id, student_id=leave.studentId, student_name=name or "Unknown",
32
+ enrollment_number=leave.student.enrollmentNumber,
33
+ start_date=leave.startDate, end_date=leave.endDate, reason=leave.reason,
34
+ document_url=leave.documentUrl, status=leave.status, approved_by=leave.approvedBy,
35
+ approver_note=leave.approverNote, created_at=leave.createdAt, updated_at=leave.updatedAt,
36
+ )
37
+
38
+
39
+ @router.post("/sessions/start", response_model=SessionResponse)
40
+ async def start_session(
41
+ data: SessionStart,
42
+ teacher: Teacher = Depends(get_current_teacher),
43
+ session_service: SessionService = Depends(),
44
+ ) -> SessionResponse:
45
+ session = await session_service.start_session(data, teacher.id)
46
+ if not session:
47
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Could not open session. Class not found or unauthorized.")
48
+ return session
49
+
50
+
51
+ @router.post("/sessions/{id}/stop", status_code=status.HTTP_200_OK)
52
+ async def stop_session(
53
+ id: str,
54
+ teacher: Teacher = Depends(get_current_teacher),
55
+ session_service: SessionService = Depends(),
56
+ ) -> dict:
57
+ if not await session_service.stop_session(id, teacher.id):
58
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session not found, already stopped, or unauthorized.")
59
+ return {"status": "success", "message": "Session closed successfully."}
60
+
61
+
62
+ @router.get("/attendance/flagged", response_model=list[FlaggedAttendanceResponse])
63
+ async def get_flagged_attendance(
64
+ teacher: Teacher = Depends(get_current_teacher),
65
+ attendance_repo: AttendanceRepository = Depends(),
66
+ ) -> list[FlaggedAttendanceResponse]:
67
+ classes = await db.academicclass.find_many(where={"teacherId": teacher.id})
68
+ my_class_ids = [c.id for c in classes]
69
+ records = await db.attendance.find_many(
70
+ where={"status": "Flagged", "session": {"is": {"academicClassId": {"in": my_class_ids}}}},
71
+ include={
72
+ "student": {"include": {"user": True}},
73
+ "session": {"include": {"academicClass": {"include": {"subject": True}}}},
74
+ },
75
+ )
76
+ return [
77
+ FlaggedAttendanceResponse(
78
+ id=r.id,
79
+ enrollment_number=r.student.enrollmentNumber if r.student else "N/A",
80
+ student_name=(
81
+ f"{r.student.firstName or ''} {r.student.lastName or ''}".strip()
82
+ if r.student else "Unknown Student"
83
+ ),
84
+ class_name=ac.subject.name if (ac := r.session.academicClass if r.session else None) and ac.subject else (ac.name if ac else "N/A"),
85
+ subject=ac.subject.name if ac and ac.subject else (ac.name if ac else "N/A"),
86
+ face_score=r.faceScore, liveness_score=r.livenessScore,
87
+ background_score=r.backgroundScore, final_ai_score=r.finalAiScore,
88
+ gps_latitude=r.gpsLatitude, gps_longitude=r.gpsLongitude,
89
+ created_at=r.createdAt,
90
+ student_note=r.studentNote,
91
+ )
92
+ for r in records
93
+ ]
94
+
95
+
96
+ @router.get("/attendance/{id}", response_model=FlaggedAttendanceResponse)
97
+ async def get_attendance_by_id(id: str, attendance_repo: AttendanceRepository = Depends()):
98
+ r = await attendance_repo.get_by_id(id)
99
+ if not r:
100
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance record not found")
101
+ ac = r.session.academicClass if r.session else None
102
+ return FlaggedAttendanceResponse(
103
+ id=r.id, enrollment_number=r.student.enrollmentNumber if r.student else "N/A",
104
+ student_name=(f"{r.student.firstName or ''} {r.student.lastName or ''}".strip() if r.student else "Unknown Student"),
105
+ class_name=ac.name if ac else "N/A", subject=ac.subject.name if ac and ac.subject else (ac.name if ac else "N/A"),
106
+ face_score=r.faceScore, liveness_score=r.livenessScore,
107
+ background_score=r.backgroundScore, final_ai_score=r.finalAiScore,
108
+ gps_latitude=r.gpsLatitude, gps_longitude=r.gpsLongitude, created_at=r.createdAt,
109
+ student_note=r.studentNote,
110
+ )
111
+
112
+
113
+ @router.put("/attendance/{id}/review", status_code=status.HTTP_200_OK)
114
+ async def review_flagged_attendance(
115
+ id: str,
116
+ review: AttendanceReview,
117
+ teacher: Teacher = Depends(get_current_teacher),
118
+ attendance_service: AttendanceService = Depends(),
119
+ ) -> dict:
120
+ if not await attendance_service.review_attendance(attendance_id=id, status=review.status, remarks=review.remarks):
121
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Attendance record not found, is not currently flagged, or review failed.")
122
+ return {"status": "success", "message": f"Attendance record has been {review.status}."}
123
+
124
+
125
+ @router.get("/my-classes", response_model=list[AcademicClassWithGeofenceResponse])
126
+ async def get_my_classes(
127
+ teacher: Teacher = Depends(get_current_teacher),
128
+ teacher_service: TeacherService = Depends(),
129
+ ) -> list[AcademicClassWithGeofenceResponse]:
130
+ return await teacher_service.get_classes_by_teacher_user_id(teacher.userId)
131
+
132
+
133
+ @router.post("/classes/{class_id}/geofence", response_model=GeofenceResponse)
134
+ async def upsert_geofence(
135
+ class_id: str, data: GeofenceUpsert,
136
+ teacher: Teacher = Depends(get_current_teacher),
137
+ teacher_service: TeacherService = Depends(),
138
+ ) -> GeofenceResponse:
139
+ return await teacher_service.upsert_geofence(user_id=teacher.userId, class_id=class_id, data=data)
140
+
141
+
142
+ @router.get("/sessions/{session_id}/attendance", response_model=SessionAttendanceResponse)
143
+ async def get_session_attendance(
144
+ session_id: str,
145
+ teacher: Teacher = Depends(get_current_teacher),
146
+ teacher_service: TeacherService = Depends(),
147
+ ) -> SessionAttendanceResponse:
148
+ return await teacher_service.get_session_attendance_roster(user_id=teacher.userId, session_id=session_id)
149
+
150
+
151
+ @router.get("/classes/{class_id}/stats", response_model=ClassStatsResponse)
152
+ async def get_class_stats(
153
+ class_id: str,
154
+ teacher: Teacher = Depends(get_current_teacher),
155
+ teacher_service: TeacherService = Depends(),
156
+ ) -> ClassStatsResponse:
157
+ return await teacher_service.get_class_stats(user_id=teacher.userId, class_id=class_id)
158
+
159
+
160
+ @router.post("/sessions/{session_id}/override", status_code=status.HTTP_200_OK)
161
+ async def manual_override_attendance(
162
+ session_id: str, data: AttendanceManualOverride,
163
+ teacher: Teacher = Depends(get_current_teacher),
164
+ teacher_service: TeacherService = Depends(),
165
+ ) -> dict:
166
+ if not await teacher_service.manual_override_attendance(user_id=teacher.userId, session_id=session_id, student_id=data.student_id, status_val=data.status):
167
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to apply attendance manual override.")
168
+ return {"status": "success", "message": f"Attendance overridden to {data.status}."}
169
+
170
+
171
+ @router.get("/sessions/all", response_model=list[SessionWithClassResponse])
172
+ async def get_teacher_sessions(
173
+ teacher: Teacher = Depends(get_current_teacher),
174
+ teacher_service: TeacherService = Depends(),
175
+ ) -> list[SessionWithClassResponse]:
176
+ return await teacher_service.get_teacher_sessions(user_id=teacher.userId)
177
+
178
+
179
+ @router.get("/sessions/{session_id}/absent-students", response_model=list[AbsentStudentItem])
180
+ async def get_absent_students(
181
+ session_id: str,
182
+ teacher: Teacher = Depends(get_current_teacher),
183
+ teacher_service: TeacherService = Depends(),
184
+ ) -> list[AbsentStudentItem]:
185
+ return await teacher_service.get_absent_students(session_id=session_id, user_id=teacher.userId)
186
+
187
+
188
+ @router.post("/sessions/{session_id}/mark-bulk", status_code=status.HTTP_200_OK)
189
+ async def bulk_mark_attendance(
190
+ session_id: str, data: BulkMarkRequest,
191
+ teacher: Teacher = Depends(get_current_teacher),
192
+ teacher_service: TeacherService = Depends(),
193
+ ) -> dict:
194
+ count = await teacher_service.bulk_mark_attendance(session_id=session_id, user_id=teacher.userId, request=data)
195
+ return {"status": "success", "count": count}
196
+
197
+
198
+ @router.get("/classes/{class_id}/export-attendance", response_model=list[dict])
199
+ async def export_class_attendance(
200
+ class_id: str,
201
+ from_date: Optional[datetime] = None,
202
+ to_date: Optional[datetime] = None,
203
+ teacher: Teacher = Depends(get_current_teacher),
204
+ teacher_service: TeacherService = Depends(),
205
+ ) -> list[dict]:
206
+ return await teacher_service.export_class_attendance(class_id=class_id, user_id=teacher.userId, from_date=from_date, to_date=to_date)
207
+
208
+
209
+ @router.get("/leaves/pending", response_model=list[LeaveRequestResponse])
210
+ async def get_pending_leaves(
211
+ teacher: Teacher = Depends(get_current_teacher),
212
+ leave_repo: LeaveRepository = Depends(),
213
+ ) -> list[LeaveRequestResponse]:
214
+ return [_make_leave_response(leave) for leave in await leave_repo.get_pending_for_teacher(teacher.id)]
215
+
216
+
217
+ @router.put("/leaves/{leave_id}/approve", status_code=status.HTTP_200_OK)
218
+ async def approve_leave(
219
+ leave_id: str, data: LeaveRequestApprove,
220
+ teacher: Teacher = Depends(get_current_teacher),
221
+ leave_service: LeaveService = Depends(),
222
+ ) -> dict:
223
+ result = await leave_service.approve_leave(leave_id=leave_id, teacher_id=teacher.id, status=data.status, approver_note=data.approver_note)
224
+ if not result:
225
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Leave request not found.")
226
+ return {"status": "success", "message": f"Leave request {data.status.lower()} successfully."}
227
+
228
+
229
+ @router.get("/device-changes/pending", response_model=list[DeviceChangeResponse])
230
+ async def get_pending_device_changes(
231
+ teacher: Teacher = Depends(get_current_teacher),
232
+ device_change_service: DeviceChangeService = Depends(),
233
+ ) -> list[DeviceChangeResponse]:
234
+ return await device_change_service.get_pending_requests(teacher_id=teacher.id)
235
+
236
+
237
+ @router.put("/device-changes/{request_id}/approve", status_code=status.HTTP_200_OK)
238
+ async def approve_device_change(
239
+ request_id: str, data: DeviceChangeApprove,
240
+ teacher: Teacher = Depends(get_current_teacher),
241
+ device_change_service: DeviceChangeService = Depends(),
242
+ ) -> dict:
243
+ result = await device_change_service.approve_request(request_id=request_id, teacher_id=teacher.id, new_status=data.status)
244
+ if not result:
245
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device change request not found or not pending.")
246
+ return {"status": "success", "message": f"Device change request {data.status.lower()} successfully."}
247
+
backend/app/api/ws.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from typing import Dict, Set
4
+
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
+
7
+ from app.api.dependencies import get_current_user_from_token
8
+ from app.core.logging_config import get_logger
9
+
10
+ logger = get_logger("app.websocket")
11
+
12
+ router = APIRouter(prefix="/ws", tags=["WebSocket"])
13
+
14
+ _PING_INTERVAL = 25
15
+ _PONG_TIMEOUT = 15
16
+
17
+
18
+ class ConnectionManager:
19
+ def __init__(self):
20
+ self.student_connections: Dict[str, Set[WebSocket]] = {}
21
+ self.teacher_connections: Dict[str, Set[WebSocket]] = {}
22
+ self._heartbeat_task: asyncio.Task | None = None
23
+
24
+ async def connect_student(self, websocket: WebSocket, student_id: str):
25
+ await websocket.accept()
26
+ self.student_connections.setdefault(student_id, set()).add(websocket)
27
+ logger.info("WebSocket student connected: %s", student_id)
28
+
29
+ async def connect_teacher(self, websocket: WebSocket, teacher_id: str):
30
+ await websocket.accept()
31
+ self.teacher_connections.setdefault(teacher_id, set()).add(websocket)
32
+ logger.info("WebSocket teacher connected: %s", teacher_id)
33
+
34
+ def disconnect(self, websocket: WebSocket, user_type: str, user_id: str):
35
+ connections = self.student_connections if user_type == "student" else self.teacher_connections
36
+ if user_id in connections:
37
+ connections[user_id].discard(websocket)
38
+ if not connections[user_id]:
39
+ del connections[user_id]
40
+ logger.info("WebSocket %s disconnected: %s", user_type, user_id)
41
+
42
+ async def send_personal_message(self, message: dict, student_id: str):
43
+ conns = self.student_connections.get(student_id)
44
+ if not conns:
45
+ return
46
+ disconnected = set()
47
+ for connection in conns:
48
+ try:
49
+ await connection.send_json(message)
50
+ except Exception as e:
51
+ logger.warning("Failed to send message to %s: %s", student_id, e)
52
+ disconnected.add(connection)
53
+ for conn in disconnected:
54
+ self.student_connections[student_id].discard(conn)
55
+
56
+ async def broadcast_to_teachers(self, message: dict):
57
+ disconnected = set()
58
+ for teacher_id, conns in self.teacher_connections.items():
59
+ for conn in conns:
60
+ try:
61
+ await conn.send_json(message)
62
+ except Exception as e:
63
+ logger.warning("Failed to send to teacher %s: %s", teacher_id, e)
64
+ disconnected.add(conn)
65
+ for conn in disconnected:
66
+ for teacher_id, conns in self.teacher_connections.items():
67
+ conns.discard(conn)
68
+ if not conns:
69
+ del self.teacher_connections[teacher_id]
70
+
71
+ async def _heartbeat_loop(self):
72
+ while True:
73
+ await asyncio.sleep(_PING_INTERVAL)
74
+ ping = {"type": "ping"}
75
+ disconnected = set()
76
+
77
+ for sid, conns in list(self.student_connections.items()):
78
+ for conn in list(conns):
79
+ try:
80
+ await asyncio.wait_for(
81
+ conn.send_json(ping), timeout=_PONG_TIMEOUT
82
+ )
83
+ except Exception:
84
+ disconnected.add((conn, "student", sid))
85
+
86
+ for tid, conns in list(self.teacher_connections.items()):
87
+ for conn in list(conns):
88
+ try:
89
+ await asyncio.wait_for(
90
+ conn.send_json(ping), timeout=_PONG_TIMEOUT
91
+ )
92
+ except Exception:
93
+ disconnected.add((conn, "teacher", tid))
94
+
95
+ for conn, utype, uid in disconnected:
96
+ self.disconnect(conn, utype, uid)
97
+
98
+ if disconnected:
99
+ logger.info(
100
+ "Heartbeat cleaned %d stale connections", len(disconnected)
101
+ )
102
+
103
+ def start_heartbeat(self):
104
+ if self._heartbeat_task is None:
105
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
106
+ logger.info("WebSocket heartbeat started")
107
+
108
+ def stop_heartbeat(self):
109
+ if self._heartbeat_task is not None:
110
+ self._heartbeat_task.cancel()
111
+ self._heartbeat_task = None
112
+ logger.info("WebSocket heartbeat stopped")
113
+
114
+ @property
115
+ def total_connections(self) -> int:
116
+ student_count = sum(len(c) for c in self.student_connections.values())
117
+ teacher_count = sum(len(c) for c in self.teacher_connections.values())
118
+ return student_count + teacher_count
119
+
120
+ manager = ConnectionManager()
121
+
122
+
123
+ @router.websocket("/connect")
124
+ async def websocket_endpoint(websocket: WebSocket):
125
+ try:
126
+ await websocket.accept()
127
+ auth_data = await websocket.receive_text()
128
+ auth_json = json.loads(auth_data)
129
+
130
+ if auth_json.get("type") != "auth" or not auth_json.get("token"):
131
+ await websocket.close(code=1008, reason="Authentication required")
132
+ return
133
+
134
+ user = await get_current_user_from_token(auth_json["token"])
135
+ if not user:
136
+ await websocket.close(code=1008, reason="Unauthorized")
137
+ return
138
+
139
+ if user.role == "STUDENT" and user.student:
140
+ student_id = user.student.id
141
+ await manager.connect_student(websocket, student_id)
142
+ await websocket.send_json({"type": "connected", "message": "WebSocket connection established", "user_id": student_id, "role": "student"})
143
+ try:
144
+ while True:
145
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=_PING_INTERVAL)
146
+ if data == "ping":
147
+ await websocket.send_json({"type": "pong"})
148
+ except asyncio.TimeoutError:
149
+ logger.info("WebSocket ping timeout for student %s", student_id)
150
+ except WebSocketDisconnect:
151
+ manager.disconnect(websocket, "student", student_id)
152
+
153
+ elif user.role == "TEACHER" and user.teacher:
154
+ teacher_id = user.teacher.id
155
+ await manager.connect_teacher(websocket, teacher_id)
156
+ await websocket.send_json({"type": "connected", "message": "WebSocket connection established", "user_id": teacher_id, "role": "teacher"})
157
+ try:
158
+ while True:
159
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=_PING_INTERVAL)
160
+ if data == "ping":
161
+ await websocket.send_json({"type": "pong"})
162
+ except asyncio.TimeoutError:
163
+ logger.info("WebSocket ping timeout for teacher %s", teacher_id)
164
+ except WebSocketDisconnect:
165
+ manager.disconnect(websocket, "teacher", teacher_id)
166
+
167
+ else:
168
+ await websocket.close(code=1008, reason="Unauthorized: Student or Teacher profile required")
169
+
170
+ except Exception as e:
171
+ logger.error("WebSocket error: %s", e, exc_info=True)
172
+ try:
173
+ await websocket.close(code=1011, reason="Internal server error")
174
+ except Exception:
175
+ pass
176
+
177
+
backend/app/core/config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import Field, field_validator
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ model_config = SettingsConfigDict(
7
+ env_file=".env",
8
+ env_file_encoding="utf-8",
9
+ extra="ignore"
10
+ )
11
+
12
+ PROJECT_NAME: str = "Smart Attendance System API"
13
+ API_V1_STR: str = "/api/v1"
14
+ DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/smart_attendance"
15
+ JWT_SECRET: str = Field(default="", description="JWT signing secret (must be set via JWT_SECRET env var)")
16
+
17
+ @field_validator("JWT_SECRET")
18
+ @classmethod
19
+ def jwt_secret_must_be_set(cls, v: str) -> str:
20
+ if not v:
21
+ raise ValueError(
22
+ "JWT_SECRET environment variable is required. "
23
+ "Set it in your .env file for security."
24
+ )
25
+ return v
26
+
27
+ JWT_ALGORITHM: str = "HS256"
28
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
29
+ REDIS_URL: str = "redis://localhost:6379/0"
30
+ UPLOAD_DIR: str = "static"
31
+ FACE_WEIGHT: float = 0.50
32
+ LIVENESS_WEIGHT: float = 0.30
33
+ BACKGROUND_WEIGHT: float = 0.20
34
+ PASS_THRESHOLD: float = 0.75
35
+ FRONTEND_URL: str = Field(default="http://localhost:3000", description="Frontend URL for CORS")
36
+ ENVIRONMENT: str = "development"
37
+ LOG_LEVEL: str = Field(default="DEBUG", description="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL")
38
+
39
+
40
+ settings = Settings()
41
+
backend/app/core/logging_config.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ _SILENCED_LOGGERS = [
4
+ "uvicorn.access", "httpx", "httpcore", "deepface",
5
+ "tensorflow", "absl", "h5py", "PIL", "numba",
6
+ "huggingface_hub", "filelock", "urllib3", "werkzeug", "asyncio",
7
+ ]
8
+
9
+ _FORMAT = logging.Formatter(
10
+ "%(asctime)s [%(levelname)-8s] [%(name)s] %(message)s",
11
+ datefmt="%Y-%m-%dT%H:%M:%S",
12
+ )
13
+
14
+
15
+ def setup_logging(level: str | None = None) -> None:
16
+ if level is None:
17
+ from app.core.config import settings
18
+ level = settings.LOG_LEVEL
19
+
20
+ effective_level = getattr(logging, level.upper(), logging.DEBUG)
21
+
22
+ console = logging.StreamHandler()
23
+ console.setFormatter(_FORMAT)
24
+
25
+ for logger_name in ["app", "app.access"]:
26
+ logger = logging.getLogger(logger_name)
27
+ logger.setLevel(effective_level)
28
+ logger.propagate = False
29
+ logger.handlers.clear()
30
+ logger.addHandler(console)
31
+
32
+ for name in _SILENCED_LOGGERS:
33
+ logging.getLogger(name).setLevel(logging.ERROR)
34
+
35
+
36
+ def get_logger(name: str) -> logging.Logger:
37
+ return logging.getLogger(name)
backend/app/core/security.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Any
3
+
4
+ import bcrypt
5
+ import jwt
6
+
7
+ from app.core.config import settings
8
+
9
+
10
+ def hash_password(password: str) -> str:
11
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
12
+
13
+
14
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
15
+ try:
16
+ return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
17
+ except (ValueError, TypeError):
18
+ return False
19
+
20
+
21
+ def create_access_token(
22
+ subject: str,
23
+ role: str,
24
+ expires_delta: timedelta | None = None,
25
+ extra_data: dict[str, Any] | None = None,
26
+ ) -> str:
27
+ expire = datetime.now(timezone.utc) + (
28
+ expires_delta if expires_delta
29
+ else timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
30
+ )
31
+ to_encode: dict[str, Any] = {
32
+ "sub": subject,
33
+ "role": role,
34
+ "exp": int(expire.timestamp()),
35
+ }
36
+ if extra_data:
37
+ to_encode.update(extra_data)
38
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
39
+
40
+
41
+ def decode_access_token(token: str) -> dict[str, Any] | None:
42
+ try:
43
+ return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
44
+ except jwt.PyJWTError:
45
+ return None
46
+
backend/app/db/client.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.logging_config import get_logger
2
+ from prisma import Prisma
3
+
4
+ logger = get_logger("app.db")
5
+
6
+ db = Prisma()
7
+
8
+
9
+ async def connect_db() -> None:
10
+ try:
11
+ await db.connect()
12
+ logger.info("Connected to database")
13
+ except Exception as e:
14
+ logger.error("Failed to connect to database: %s", e, exc_info=True)
15
+ raise
16
+
17
+
18
+ async def disconnect_db() -> None:
19
+ try:
20
+ if db.is_connected():
21
+ await db.disconnect()
22
+ except Exception as e:
23
+ logger.error("Error disconnecting database: %s", e, exc_info=True)
24
+
backend/app/db/redis.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from redis.asyncio import Redis
2
+ from app.core.config import settings
3
+ from app.core.logging_config import get_logger
4
+
5
+ logger = get_logger("app.redis")
6
+
7
+ redis_client: Redis | None = None
8
+
9
+
10
+ async def connect_redis() -> Redis:
11
+ global redis_client
12
+ if redis_client is None:
13
+ try:
14
+ redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
15
+ await redis_client.ping()
16
+ logger.info("Connected to Redis")
17
+ except Exception as err:
18
+ logger.error("Failed to connect to Redis at %s: %s", settings.REDIS_URL, err, exc_info=True)
19
+ redis_client = None
20
+ raise
21
+ return redis_client
22
+
23
+
24
+ async def disconnect_redis() -> None:
25
+ global redis_client
26
+ if redis_client is not None:
27
+ try:
28
+ await redis_client.close()
29
+ except Exception as err:
30
+ logger.error("Error closing Redis connection: %s", err, exc_info=True)
31
+ finally:
32
+ redis_client = None
33
+
34
+
35
+ def get_redis() -> Redis:
36
+ if redis_client is None:
37
+ raise RuntimeError("Redis client is not initialized. Please call connect_redis during startup.")
38
+ return redis_client
39
+
backend/app/middleware/request_logging.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from fastapi import Request, Response
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from app.core.logging_config import get_logger
5
+
6
+ logger = get_logger("app.access")
7
+
8
+
9
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
10
+ async def dispatch(self, request: Request, call_next) -> Response:
11
+ start_ts = time.perf_counter()
12
+ method = request.method
13
+ path = request.url.path
14
+
15
+ try:
16
+ response: Response = await call_next(request)
17
+ except Exception as exc:
18
+ elapsed_ms = int((time.perf_counter() - start_ts) * 1000)
19
+ logger.error("%s %s — %dms | error=%s", method, path, elapsed_ms, exc, exc_info=True)
20
+ raise
21
+
22
+ elapsed_ms = int((time.perf_counter() - start_ts) * 1000)
23
+ log = logger.warning if response.status_code >= 400 else logger.info
24
+ log("%s %s %d %dms", method, path, response.status_code, elapsed_ms)
25
+ return response
backend/app/repositories/attendance_repo.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from prisma.models import Attendance
4
+ from app.db.client import db
5
+
6
+
7
+ class AttendanceRepository:
8
+ async def get_by_id(self, attendance_id: str) -> Attendance | None:
9
+ return await db.attendance.find_unique(
10
+ where={"id": attendance_id},
11
+ include={
12
+ "student": {"include": {"user": True}},
13
+ "session": {"include": {"academicClass": {"include": {"subject": True}}}},
14
+ },
15
+ )
16
+
17
+ async def get_by_student_and_session(self, student_id: str, session_id: str) -> Attendance | None:
18
+ return await db.attendance.find_unique(
19
+ where={"studentId_sessionId": {"studentId": student_id, "sessionId": session_id}}
20
+ )
21
+
22
+ async def get_flagged(self) -> List[Attendance]:
23
+ return await db.attendance.find_many(
24
+ where={"status": "Flagged"},
25
+ include={
26
+ "student": {"include": {"user": True}},
27
+ "session": {"include": {"academicClass": {"include": {"subject": True}}}},
28
+ },
29
+ )
30
+
31
+ async def create(self, data_dict: dict) -> Attendance:
32
+ return await db.attendance.create(data=data_dict)
33
+
34
+ async def update_review(self, attendance_id: str, status: str, remarks: str) -> Attendance:
35
+ return await db.attendance.update(
36
+ where={"id": attendance_id}, data={"status": status, "remarks": remarks}
37
+ )
38
+
39
+ async def get_all_absences(self) -> List[Attendance]:
40
+ return await db.attendance.find_many(
41
+ where={"status": {"in": ["Absent", "Rejected"]}}, include={"student": True}
42
+ )
43
+
44
+ async def get_by_student_id(self, student_id: str) -> List[Attendance]:
45
+ return await db.attendance.find_many(
46
+ where={"studentId": student_id},
47
+ include={"session": {"include": {"academicClass": True}}},
48
+ order={"createdAt": "desc"},
49
+ )
50
+
51
+ async def get_by_session_id(self, session_id: str) -> List[Attendance]:
52
+ return await db.attendance.find_many(
53
+ where={"sessionId": session_id}, include={"student": True}
54
+ )
55
+
56
+ async def get_by_student_in_date_range(self, student_id: str, start_date, end_date) -> List[Attendance]:
57
+ return await db.attendance.find_many(
58
+ where={"studentId": student_id, "createdAt": {"gte": start_date, "lte": end_date}},
59
+ include={"session": True},
60
+ order={"createdAt": "desc"},
61
+ )
62
+
63
+ async def update(self, attendance_id: str, data: dict) -> Attendance:
64
+ return await db.attendance.update(where={"id": attendance_id}, data=data)
backend/app/repositories/class_repo.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from prisma.models import AcademicClass
4
+ from app.db.client import db
5
+
6
+
7
+ class ClassRepository:
8
+ async def get_by_id(self, class_id: str) -> AcademicClass | None:
9
+ return await db.academicclass.find_unique(where={"id": class_id})
10
+
11
+ async def get_by_teacher_id(self, teacher_id: str) -> List[AcademicClass]:
12
+ return await db.academicclass.find_many(where={"teacherId": teacher_id})
backend/app/repositories/enrollment_repo.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from prisma.models import Enrollment
4
+ from app.db.client import db
5
+
6
+
7
+ class EnrollmentRepository:
8
+ async def get_by_student_id(self, student_id: str) -> List[Enrollment]:
9
+ return await db.enrollment.find_many(
10
+ where={"studentId": student_id}, include={"academicClass": True}
11
+ )
12
+
13
+ async def get_by_class_id(self, class_id: str) -> List[Enrollment]:
14
+ return await db.enrollment.find_many(
15
+ where={"academicClassId": class_id}, include={"student": True}
16
+ )
17
+
18
+ async def enroll_student(self, student_id: str, class_id: str) -> Enrollment:
19
+ return await db.enrollment.create(data={"studentId": student_id, "academicClassId": class_id})
backend/app/repositories/geofence_repo.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from prisma.models import Geofence
2
+ from app.db.client import db
3
+
4
+
5
+ class GeofenceRepository:
6
+ async def get_by_class_id(self, class_id: str) -> Geofence | None:
7
+ return await db.geofence.find_unique(where={"academicClassId": class_id})
8
+
9
+ async def upsert_geofence(self, class_id: str, latitude: float, longitude: float, radius: float) -> Geofence:
10
+ existing = await self.get_by_class_id(class_id)
11
+ if existing:
12
+ return await db.geofence.update(
13
+ where={"academicClassId": class_id},
14
+ data={"latitude": latitude, "longitude": longitude, "radiusMeters": radius},
15
+ )
16
+ return await db.geofence.create(
17
+ data={"academicClassId": class_id, "latitude": latitude, "longitude": longitude, "radiusMeters": radius}
18
+ )
backend/app/repositories/leave_repo.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from typing import List, Optional
3
+
4
+ from prisma.models import LeaveRequest
5
+ from app.db.client import db
6
+
7
+
8
+ class LeaveRepository:
9
+ async def create(self, data: dict) -> LeaveRequest:
10
+ return await db.leaverequest.create(data=data, include={"student": True})
11
+
12
+ async def get_by_id(self, leave_id: str) -> Optional[LeaveRequest]:
13
+ return await db.leaverequest.find_unique(where={"id": leave_id}, include={"student": True})
14
+
15
+ async def get_by_student_id(self, student_id: str) -> List[LeaveRequest]:
16
+ return await db.leaverequest.find_many(
17
+ where={"studentId": student_id}, include={"student": True}, order={"createdAt": "desc"}
18
+ )
19
+
20
+ async def get_pending_for_teacher(self, teacher_id: str) -> List[LeaveRequest]:
21
+ enrollments = await db.enrollment.find_many(
22
+ where={"academicClass": {"is": {"teacherId": teacher_id}}},
23
+ include={"student": True},
24
+ )
25
+ student_ids = [e.studentId for e in enrollments]
26
+ return await db.leaverequest.find_many(
27
+ where={"studentId": {"in": student_ids}, "status": "PENDING"},
28
+ include={"student": True},
29
+ order={"createdAt": "asc"},
30
+ )
31
+
32
+ async def update_status(
33
+ self, leave_id: str, status: str, approved_by: str, approver_note: Optional[str] = None
34
+ ) -> Optional[LeaveRequest]:
35
+ return await db.leaverequest.update(
36
+ where={"id": leave_id},
37
+ data={
38
+ "status": status,
39
+ "approvedBy": approved_by,
40
+ "approverNote": approver_note,
41
+ "updatedAt": datetime.now(timezone.utc),
42
+ },
43
+ )
44
+
backend/app/repositories/session_repo.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from prisma.models import Session
4
+ from app.db.client import db
5
+
6
+
7
+ class SessionRepository:
8
+ async def get_by_id(self, session_id: str) -> Session | None:
9
+ return await db.session.find_unique(where={"id": session_id})
10
+
11
+ async def get_active_session_by_class(self, class_id: str) -> Session | None:
12
+ now = datetime.now(timezone.utc)
13
+ return await db.session.find_first(
14
+ where={"academicClassId": class_id, "isActive": True, "endTime": {"gt": now}}
15
+ )
16
+
17
+ async def create(self, class_id: str, start_time: datetime, end_time: datetime) -> Session:
18
+ return await db.session.create(
19
+ data={"academicClassId": class_id, "startTime": start_time, "endTime": end_time, "isActive": True}
20
+ )
21
+
22
+ async def deactivate(self, session_id: str) -> Session:
23
+ return await db.session.update(where={"id": session_id}, data={"isActive": False})
24
+
25
+ async def get_sessions_in_date_range(self, start_date: datetime, end_date: datetime):
26
+ return await db.session.find_many(
27
+ where={"startTime": {"gte": start_date, "lte": end_date}},
28
+ include={"academicClass": True},
29
+ )
30
+
31
+ async def get_by_class_id(self, class_id: str):
32
+ return await db.session.find_many(
33
+ where={"academicClassId": class_id}, order={"startTime": "desc"}
34
+ )
backend/app/repositories/student_repo.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from prisma.models import Student
4
+ from app.db.client import db
5
+
6
+
7
+ class StudentRepository:
8
+ async def get_by_id(self, student_id: str) -> Student | None:
9
+ return await db.student.find_unique(where={"id": student_id}, include={"user": True})
10
+
11
+ async def get_by_user_id(self, user_id: str) -> Student | None:
12
+ return await db.student.find_unique(where={"userId": user_id}, include={"user": True})
13
+
14
+ async def get_by_enrollment(self, enrollment: str) -> Student | None:
15
+ return await db.student.find_unique(where={"enrollmentNumber": enrollment}, include={"user": True})
16
+
17
+ async def create(self, user_id: str, enrollment: str) -> Student:
18
+ return await db.student.create(data={"userId": user_id, "enrollmentNumber": enrollment})
19
+
20
+ async def update_face_embedding(self, student_id: str, embedding: List[float]) -> bool:
21
+ await db.execute_raw("UPDATE students SET face_embedding = $1::vector WHERE id = $2", embedding, student_id)
22
+ return True
23
+
24
+ async def get_face_embedding(self, student_id: str) -> List[float] | None:
25
+ records = await db.query_raw("SELECT face_embedding::text FROM students WHERE id = $1", student_id)
26
+ if not records or not records[0].get("face_embedding"):
27
+ return None
28
+ val = records[0]["face_embedding"]
29
+ if isinstance(val, list):
30
+ return [float(x) for x in val]
31
+ if isinstance(val, str):
32
+ cleaned = val.strip("[]")
33
+ return [float(x) for x in cleaned.split(",")] if cleaned else []
34
+ return None
35
+
36
+ async def update_streak(self, student_id: str, current_streak: int, highest_streak: int) -> bool:
37
+ await db.student.update(
38
+ where={"id": student_id},
39
+ data={"currentStreak": current_streak, "highestStreak": highest_streak},
40
+ )
41
+ return True
42
+
43
+ async def get_all_active(self) -> List[Student]:
44
+ return await db.student.find_many(
45
+ where={"user": {"is": {"isActive": True}}},
46
+ include={"user": True},
47
+ )
backend/app/repositories/system_config_repo.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from prisma.models import SystemConfiguration
2
+ from app.db.client import db
3
+
4
+
5
+ class SystemConfigRepository:
6
+ async def get_config(self) -> SystemConfiguration:
7
+ """
8
+ Gets the system configuration. If it doesn't exist, creates a default one.
9
+ """
10
+ config = await db.systemconfiguration.find_first()
11
+ if not config:
12
+ config = await db.systemconfiguration.create(
13
+ data={
14
+ "isFaceRecognitionEnabled": True,
15
+ "isGpsVerificationEnabled": True,
16
+ "isAiBackgroundValidationEnabled": True,
17
+ }
18
+ )
19
+ return config
20
+
21
+ async def update_config(
22
+ self,
23
+ is_face_recognition_enabled: bool | None = None,
24
+ is_gps_verification_enabled: bool | None = None,
25
+ is_ai_background_validation_enabled: bool | None = None,
26
+ ) -> SystemConfiguration:
27
+ config = await self.get_config()
28
+
29
+ update_data = {}
30
+ if is_face_recognition_enabled is not None:
31
+ update_data["isFaceRecognitionEnabled"] = is_face_recognition_enabled
32
+ if is_gps_verification_enabled is not None:
33
+ update_data["isGpsVerificationEnabled"] = is_gps_verification_enabled
34
+ if is_ai_background_validation_enabled is not None:
35
+ update_data["isAiBackgroundValidationEnabled"] = is_ai_background_validation_enabled
36
+
37
+ return await db.systemconfiguration.update(
38
+ where={"id": config.id},
39
+ data=update_data,
40
+ )
backend/app/repositories/teacher_repo.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from prisma.models import Teacher
2
+ from app.db.client import db
3
+
4
+
5
+ class TeacherRepository:
6
+ async def get_by_id(self, teacher_id: str) -> Teacher | None:
7
+ return await db.teacher.find_unique(where={"id": teacher_id}, include={"user": True})
8
+
9
+ async def get_by_user_id(self, user_id: str) -> Teacher | None:
10
+ return await db.teacher.find_unique(where={"userId": user_id}, include={"user": True})
backend/app/repositories/user_repo.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from prisma.models import User
2
+ from app.db.client import db
3
+
4
+
5
+ class UserRepository:
6
+ async def get_by_email(self, email: str) -> User | None:
7
+ return await db.user.find_unique(where={"email": email})
8
+
9
+ async def get_by_id(self, user_id: str) -> User | None:
10
+ return await db.user.find_unique(where={"id": user_id})
11
+
12
+ async def create(self, email: str, password_hash: str, role: str) -> User:
13
+ return await db.user.create(data={"email": email, "hashedPassword": password_hash, "role": role})
backend/app/schemas/admin.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, Field, ConfigDict
5
+
6
+
7
+ class ClassCreate(BaseModel):
8
+ name: str = Field(..., min_length=2, max_length=100, description="Name of the class e.g. CS-101-A")
9
+ subject_id: str = Field(..., description="UUID of the linked Subject")
10
+ teacher_id: str = Field(..., min_length=36, max_length=36, description="UUID of the associated Teacher profile")
11
+ classroom_id: Optional[str] = Field(None, description="UUID of the assigned Classroom (optional)")
12
+ semester: Optional[int] = Field(None, ge=1, le=8, description="Academic semester (1–8)")
13
+ batch: Optional[str] = Field(None, max_length=20, description="Batch year range e.g. 2022-2026")
14
+ max_students: Optional[int] = Field(None, ge=1, description="Maximum student capacity for the class")
15
+
16
+
17
+ class ClassUpdate(BaseModel):
18
+ name: Optional[str] = Field(None, min_length=2, max_length=100)
19
+ subject_id: Optional[str] = Field(None)
20
+ teacher_id: Optional[str] = Field(None, min_length=36, max_length=36)
21
+ classroom_id: Optional[str] = Field(None)
22
+ semester: Optional[int] = Field(None, ge=1, le=8)
23
+ batch: Optional[str] = Field(None, max_length=20)
24
+ max_students: Optional[int] = Field(None, ge=1)
25
+
26
+
27
+ class AssignTeacherRequest(BaseModel):
28
+ teacher_id: str = Field(..., min_length=36, max_length=36, description="UUID of the Teacher profile to assign")
29
+
30
+
31
+ class EnrollRequest(BaseModel):
32
+ student_ids: list[str] = Field(..., min_length=1, description="List of Student UUIDs to enroll in the class")
33
+
34
+
35
+ class ClassResponse(BaseModel):
36
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
37
+
38
+ id: str = Field(..., description="Unique UUID of the Academic Class")
39
+ name: str = Field(..., description="Name of the class")
40
+ subject_name: str = Field(..., description="Resolved subject name")
41
+ subject_code: str = Field(..., description="Resolved subject code")
42
+ teacher_id: str = Field(..., alias="teacherId", description="Teacher ID associated with this class")
43
+ classroom_name: Optional[str] = Field(None, description="Resolved classroom name")
44
+ semester: Optional[int] = Field(None, description="Academic semester")
45
+ batch: Optional[str] = Field(None, description="Batch year range")
46
+ max_students: Optional[int] = Field(None, description="Maximum student capacity")
47
+ enrolled_count: int = Field(0, description="Current number of enrolled students")
48
+ enrolled_student_ids: list[str] = Field(default_factory=list, description="List of student IDs currently enrolled")
49
+
50
+
51
+ class DepartmentCreate(BaseModel):
52
+ name: str = Field(..., min_length=3, max_length=100)
53
+ code: str = Field(..., min_length=2, max_length=10)
54
+ head: Optional[str] = None
55
+ description: Optional[str] = None
56
+
57
+
58
+ class DepartmentUpdate(BaseModel):
59
+ name: Optional[str] = Field(None, min_length=3, max_length=100)
60
+ code: Optional[str] = Field(None, min_length=2, max_length=10)
61
+ head: Optional[str] = None
62
+ description: Optional[str] = None
63
+
64
+
65
+ class DepartmentResponse(BaseModel):
66
+ model_config = ConfigDict(from_attributes=True)
67
+
68
+ id: str
69
+ name: str
70
+ code: str
71
+ head: Optional[str] = None
72
+ description: Optional[str] = None
73
+ classCount: int = 0
74
+
75
+
76
+ class AuditLogResponse(BaseModel):
77
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
78
+
79
+ id: str
80
+ timestamp: datetime
81
+ eventType: str
82
+ severity: str
83
+ actor: str
84
+ target: str
85
+ description: str
86
+ ip: Optional[str] = Field(None, alias="ipAddress")
87
+ meta: Optional[dict] = Field(None, alias="metadata")
88
+
89
+
90
+ class AdminStatsResponse(BaseModel):
91
+ studentCount: int
92
+ teacherCount: int
93
+ classCount: int
94
+
95
+
96
+ class AdminResetPasswordRequest(BaseModel):
97
+ new_password: str = Field(..., min_length=8, description="New password for the user")
backend/app/schemas/attendance.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, Field, ConfigDict
5
+
6
+
7
+ class AttendanceMarkResponse(BaseModel):
8
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
9
+
10
+ id: str = Field(..., description="Unique UUID of the attendance record")
11
+ student_id: str = Field(..., validation_alias="studentId", description="Student UUID")
12
+ session_id: str = Field(..., validation_alias="sessionId", description="Session UUID")
13
+ status: str = Field(..., description="Outcome of the weighted decision: 'Present' or 'Flagged'")
14
+ face_score: float = Field(..., validation_alias="faceScore", description="AI Face similarity score (0.0 to 1.0)")
15
+ liveness_score: float = Field(..., validation_alias="livenessScore", description="AI Face liveness score (0.0 to 1.0)")
16
+ background_score: float = Field(..., validation_alias="backgroundScore", description="AI Background learning-environment score (0.0 to 1.0)")
17
+ final_ai_score: float = Field(..., validation_alias="finalAiScore", description="Composite decision engine score (0.0 to 1.0)")
18
+ gps_latitude: float = Field(..., validation_alias="gpsLatitude", description="Submitted GPS Latitude")
19
+ gps_longitude: float = Field(..., validation_alias="gpsLongitude", description="Submitted GPS Longitude")
20
+ created_at: datetime = Field(..., validation_alias="createdAt", description="Verification timestamp")
21
+
22
+
23
+ class FlaggedAttendanceResponse(BaseModel):
24
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
25
+
26
+ id: str = Field(..., description="Attendance Record UUID")
27
+ enrollment_number: str = Field(..., description="Student enrollment number")
28
+ student_name: str = Field(..., description="Student full email or name context")
29
+ class_name: str = Field(..., description="Class name")
30
+ subject: str = Field(..., description="Course Subject")
31
+ face_score: float = Field(..., validation_alias="faceScore", description="Similarity confidence")
32
+ liveness_score: float = Field(..., validation_alias="livenessScore", description="Liveness confidence")
33
+ background_score: float = Field(..., validation_alias="backgroundScore", description="Background confidence")
34
+ final_ai_score: float = Field(..., validation_alias="finalAiScore", description="Composite engine decision score")
35
+ gps_latitude: float = Field(..., validation_alias="gpsLatitude", description="Submitted GPS Latitude")
36
+ gps_longitude: float = Field(..., validation_alias="gpsLongitude", description="Submitted GPS Longitude")
37
+ created_at: datetime = Field(..., validation_alias="createdAt", description="Verification timestamp")
38
+ student_note: Optional[str] = Field(None, validation_alias="studentNote", description="Student note on flagged record")
39
+
40
+
41
+ class AttendanceAnalyzeResponse(BaseModel):
42
+ """Returned by the analyze endpoint — scores only, no record saved yet."""
43
+ face_score: float = Field(..., description="AI Face similarity score (0.0 to 1.0)")
44
+ liveness_score: float = Field(..., description="AI Face liveness score (0.0 to 1.0)")
45
+ background_score: float = Field(..., description="AI Background score (0.0 to 1.0)")
46
+ final_ai_score: float = Field(..., description="Composite weighted score (0.0 to 1.0)")
47
+ predicted_status: str = Field(..., description="'Present' or 'Flagged' — what would be saved on confirm")
48
+ review_token: str = Field(..., description="Short-lived signed token to confirm submission without re-running AI")
49
+
50
+
51
+ class AttendanceReview(BaseModel):
52
+ status: str = Field(..., pattern="^(Approved|Rejected)$", description="Review decision: 'Approved' or 'Rejected'")
53
+ remarks: Optional[str] = Field(default="", max_length=250, description="Audit notes/justification from the teacher")
54
+
55
+
56
+
backend/app/schemas/auth.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, EmailStr, Field
4
+
5
+
6
+ class UserLogin(BaseModel):
7
+ email: EmailStr = Field(..., description="Unique email address of the user")
8
+ password: str = Field(..., min_length=8, max_length=100, description="Plaintext password")
9
+ device_uuid: Optional[str] = Field(None, description="Hardware device UUID for student device binding")
10
+
11
+
12
+ class Token(BaseModel):
13
+ access_token: str = Field(..., description="Signed JWT access token")
14
+ token_type: str = Field("bearer", description="Token protocol type")
15
+ role: str = Field(..., description="Role of the authenticated user (STUDENT, TEACHER, ADMIN)")
16
+
17
+
18
+ class UserProfileResponse(BaseModel):
19
+ id: str = Field(..., description="Unique UUID of the user")
20
+ email: EmailStr = Field(..., description="Email address of the user")
21
+ role: str = Field(..., description="Assigned role of the user")
22
+ is_active: bool = Field(..., description="System status flag")
23
+ student_profile: Optional[dict] = Field(None, description="Detailed student profile if role is STUDENT")
24
+ teacher_profile: Optional[dict] = Field(None, description="Detailed teacher profile if role is TEACHER")
25
+
26
+
27
+ class DeviceChangeRequestCreate(BaseModel):
28
+ email: EmailStr = Field(..., description="Unique email address of the user")
29
+ password: str = Field(..., min_length=8, max_length=100, description="Plaintext password")
30
+ new_device_uuid: str = Field(..., description="The new hardware device UUID")
31
+ reason: Optional[str] = Field(None, description="Optional reason for changing the device")
backend/app/schemas/leave.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, date
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, Field, field_validator, ConfigDict
5
+
6
+
7
+ class LeaveRequestCreate(BaseModel):
8
+ start_date: date = Field(..., description="Leave start date")
9
+ end_date: date = Field(..., description="Leave end date")
10
+ reason: str = Field(..., min_length=10, max_length=500, description="Reason for leave")
11
+ document_url: Optional[str] = Field(None, description="Supporting document URL (medical certificate, etc.)")
12
+
13
+ @field_validator('end_date')
14
+ @classmethod
15
+ def validate_date_range(cls, v, info):
16
+ if 'start_date' in info.data and v < info.data['start_date']:
17
+ raise ValueError('end_date must be after or equal to start_date')
18
+ return v
19
+
20
+
21
+ class LeaveRequestResponse(BaseModel):
22
+ id: str
23
+ student_id: str
24
+ student_name: str
25
+ enrollment_number: str
26
+ start_date: datetime
27
+ end_date: datetime
28
+ reason: str
29
+ document_url: Optional[str]
30
+ status: str
31
+ approved_by: Optional[str]
32
+ approver_note: Optional[str]
33
+ created_at: datetime
34
+ updated_at: datetime
35
+
36
+ model_config = ConfigDict(from_attributes=True)
37
+
38
+
39
+ class LeaveRequestApprove(BaseModel):
40
+ status: str = Field(..., pattern="^(APPROVED|REJECTED)$", description="Approval status")
41
+ approver_note: Optional[str] = Field(None, max_length=300, description="Optional note from approver")
42
+
43
+
44
+ class LeaveRequestListResponse(BaseModel):
45
+ leaves: list[LeaveRequestResponse]
46
+ total: int
47
+ pending: int
48
+ approved: int
49
+ rejected: int
backend/app/schemas/log.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Dict, Any
2
+ from datetime import datetime, timezone
3
+
4
+ from pydantic import BaseModel, Field, field_validator
5
+
6
+ _VALID_SOURCES = {"frontend", "mobile"}
7
+ _VALID_LEVELS = {"DEBUG", "INFO", "WARN", "WARNING", "ERROR", "CRITICAL"}
8
+
9
+
10
+ class LogEvent(BaseModel):
11
+ source: str = Field(..., description="Origin of the log event: 'frontend' or 'mobile'")
12
+ level: str = Field(default="INFO", description="Severity: DEBUG, INFO, WARN, ERROR, CRITICAL")
13
+ message: str = Field(..., min_length=1, description="The human-readable log message")
14
+ timestamp: Optional[str] = Field(default=None, description="ISO-8601 UTC timestamp of the event")
15
+ context: Optional[Dict[str, Any]] = Field(default=None, description="Optional structured metadata")
16
+ user_id: Optional[str] = Field(default=None, description="Authenticated user ID at the time of the event")
17
+ platform_version: Optional[str] = Field(default=None, description="Client platform version")
18
+
19
+ @field_validator("source")
20
+ @classmethod
21
+ def validate_source(cls, value: str) -> str:
22
+ if value.lower() not in _VALID_SOURCES:
23
+ raise ValueError(f"Invalid log source '{value}'. Must be one of: {sorted(_VALID_SOURCES)}")
24
+ return value.lower()
25
+
26
+ @field_validator("level")
27
+ @classmethod
28
+ def validate_level(cls, value: str) -> str:
29
+ normalized = value.upper()
30
+ return normalized if normalized in _VALID_LEVELS else "INFO"
31
+
32
+ @field_validator("timestamp", mode="before")
33
+ @classmethod
34
+ def set_default_timestamp(cls, value: Optional[str]) -> str:
35
+ return value if value is not None else datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
backend/app/schemas/master_data.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, ConfigDict
2
+
3
+
4
+
5
+ class SubjectCreate(BaseModel):
6
+ name: str = Field(..., min_length=3, max_length=100)
7
+ code: str = Field(..., min_length=2, max_length=10)
8
+ description: str | None = None
9
+
10
+
11
+ class SubjectUpdate(BaseModel):
12
+ name: str | None = Field(None, min_length=3, max_length=100)
13
+ code: str | None = Field(None, min_length=2, max_length=10)
14
+ description: str | None = None
15
+
16
+
17
+ class SubjectResponse(BaseModel):
18
+ model_config = ConfigDict(from_attributes=True)
19
+
20
+ id: str
21
+ name: str
22
+ code: str
23
+ description: str | None = None
24
+
25
+
26
+ class ClassroomCreate(BaseModel):
27
+ name: str = Field(..., min_length=1, max_length=100)
28
+ building: str | None = None
29
+ capacity: int | None = Field(None, ge=1)
30
+
31
+
32
+ class ClassroomUpdate(BaseModel):
33
+ name: str | None = Field(None, min_length=1, max_length=100)
34
+ building: str | None = None
35
+ capacity: int | None = Field(None, ge=1)
36
+
37
+
38
+ class ClassroomResponse(BaseModel):
39
+ model_config = ConfigDict(from_attributes=True)
40
+
41
+ id: str
42
+ name: str
43
+ building: str | None = None
44
+ capacity: int | None = None
45
+
46
+
47
+ class DesignationCreate(BaseModel):
48
+ name: str = Field(..., min_length=3, max_length=100)
49
+ code: str = Field(..., min_length=2, max_length=10)
50
+ description: str | None = None
51
+
52
+
53
+ class DesignationUpdate(BaseModel):
54
+ name: str | None = Field(None, min_length=3, max_length=100)
55
+ code: str | None = Field(None, min_length=2, max_length=10)
56
+ description: str | None = None
57
+
58
+
59
+ class DesignationResponse(BaseModel):
60
+ model_config = ConfigDict(from_attributes=True)
61
+
62
+ id: str
63
+ name: str
64
+ code: str
65
+ description: str | None = None
backend/app/schemas/student.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, EmailStr, Field, ConfigDict
5
+
6
+
7
+ class StudentCreate(BaseModel):
8
+ email: EmailStr = Field(..., description="Unique email address of the student")
9
+ password: str = Field(..., min_length=8, max_length=100, description="Secure account password")
10
+ enrollment_number: str = Field(..., min_length=5, max_length=30, description="University Enrollment Number")
11
+ first_name: str = Field(..., min_length=1, max_length=100, description="Student first name")
12
+ last_name: str = Field(..., min_length=1, max_length=100, description="Student last name")
13
+ phone: Optional[str] = Field(None, max_length=20, description="Contact phone number")
14
+ gender: Optional[str] = Field(None, description="Gender identity")
15
+ date_of_birth: Optional[datetime] = Field(None, description="Date of birth")
16
+ semester: Optional[int] = Field(None, ge=1, le=8, description="Current academic semester (1–8)")
17
+ batch: Optional[str] = Field(None, max_length=20, description="Batch year range e.g. 2022-2026")
18
+ department_id: Optional[str] = Field(None, description="UUID of the student's department")
19
+
20
+
21
+ class StudentUpdate(BaseModel):
22
+ enrollment_number: Optional[str] = Field(None, min_length=5, max_length=30)
23
+ first_name: Optional[str] = Field(None, min_length=1, max_length=100)
24
+ last_name: Optional[str] = Field(None, min_length=1, max_length=100)
25
+ phone: Optional[str] = Field(None, max_length=20)
26
+ gender: Optional[str] = Field(None)
27
+ date_of_birth: Optional[datetime] = Field(None)
28
+ semester: Optional[int] = Field(None, ge=1, le=8)
29
+ batch: Optional[str] = Field(None, max_length=20)
30
+ department_id: Optional[str] = Field(None)
31
+
32
+
33
+ class StudentResponse(BaseModel):
34
+ model_config = ConfigDict(from_attributes=True)
35
+
36
+ id: str = Field(..., description="Unique UUID of the student record")
37
+ user_id: str = Field(..., description="Mapped User UUID")
38
+ enrollment_number: str = Field(..., description="Student enrollment number")
39
+ email: str = Field(..., description="Email address associated with the user profile")
40
+ first_name: Optional[str] = Field(None, description="Student first name")
41
+ last_name: Optional[str] = Field(None, description="Student last name")
42
+ phone: Optional[str] = Field(None, description="Contact phone number")
43
+ gender: Optional[str] = Field(None, description="Gender identity")
44
+ date_of_birth: Optional[datetime] = Field(None, description="Date of birth")
45
+ department_id: Optional[str] = Field(None, description="Raw department UUID")
46
+ department_name: Optional[str] = Field(None, description="Resolved department name")
47
+ semester: Optional[int] = Field(None, description="Current semester")
48
+ batch: Optional[str] = Field(None, description="Batch year range")
49
+
50
+
51
+ class StudentAttendanceItem(BaseModel):
52
+ attendance_id: str = Field(..., description="UUID of attendance record")
53
+ class_id: str = Field(..., description="Class UUID")
54
+ class_name: str = Field(..., description="Class name")
55
+ subject: str = Field(..., description="Subject name")
56
+ session_id: str = Field(..., description="Session UUID")
57
+ status: str = Field(..., description="Attendance status (Present, Flagged, Absent)")
58
+ marked_at: datetime = Field(..., description="Timestamp marked")
59
+ face_score: Optional[float] = Field(None, description="AI face similarity score")
60
+ liveness_score: Optional[float] = Field(None, description="AI liveness score")
61
+ background_score: Optional[float] = Field(None, description="AI background score")
62
+ final_ai_score: Optional[float] = Field(None, description="Composite AI score")
63
+ teacher_note: Optional[str] = Field(None, description="Teacher review note/remarks")
64
+
65
+
66
+ class StudentAttendanceHistoryResponse(BaseModel):
67
+ student_id: str = Field(..., description="Student profile UUID")
68
+ overall_attendance_percentage: float = Field(..., description="Overall attendance percentage over all enrolled courses")
69
+ history: list[StudentAttendanceItem] = Field(..., description="Detailed history itemized logs")
70
+
71
+
72
+ class StudentClassResponse(BaseModel):
73
+ class_id: str = Field(..., description="Class UUID")
74
+ class_name: str = Field(..., description="Class name")
75
+ subject: str = Field(..., description="Subject name")
76
+ teacher_name: str = Field(..., description="Teacher name")
77
+ active_session_id: Optional[str] = Field(None, description="UUID of active session if any")
78
+ session_end_time: Optional[datetime] = Field(None, description="Active session end time if any")
79
+ latitude: Optional[float] = Field(None, description="Geofence center latitude")
80
+ longitude: Optional[float] = Field(None, description="Geofence center longitude")
81
+ radius_meters: Optional[float] = Field(None, description="Geofence radius in meters")
backend/app/schemas/system_config.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class SystemConfigResponse(BaseModel):
5
+ is_face_recognition_enabled: bool = Field(..., alias="isFaceRecognitionEnabled")
6
+ is_gps_verification_enabled: bool = Field(..., alias="isGpsVerificationEnabled")
7
+ is_ai_background_validation_enabled: bool = Field(..., alias="isAiBackgroundValidationEnabled")
8
+
9
+ class Config:
10
+ populate_by_name = True
11
+
12
+
13
+ class SystemConfigUpdate(BaseModel):
14
+ is_face_recognition_enabled: bool | None = Field(None, alias="isFaceRecognitionEnabled")
15
+ is_gps_verification_enabled: bool | None = Field(None, alias="isGpsVerificationEnabled")
16
+ is_ai_background_validation_enabled: bool | None = Field(None, alias="isAiBackgroundValidationEnabled")
backend/app/schemas/teacher.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, Literal
3
+
4
+ from pydantic import BaseModel, EmailStr, Field, ConfigDict
5
+
6
+
7
+ class TeacherCreate(BaseModel):
8
+ email: EmailStr = Field(..., description="Unique email address of the teacher")
9
+ password: str = Field(..., min_length=8, max_length=100, description="Secure account password")
10
+ employee_id: str = Field(..., min_length=3, max_length=30, description="Unique employee identifier e.g. EMP2024001")
11
+ first_name: str = Field(..., min_length=1, max_length=100, description="Teacher first name")
12
+ last_name: str = Field(..., min_length=1, max_length=100, description="Teacher last name")
13
+ department_id: str = Field(..., description="UUID of the teacher's department")
14
+ designation_id: str = Field(..., description="UUID of the teacher's designation")
15
+ phone: Optional[str] = Field(None, max_length=20, description="Contact phone number")
16
+ qualification: Optional[str] = Field(None, max_length=100, description="Academic qualification e.g. Ph.D, M.Tech")
17
+ specialization: Optional[str] = Field(None, max_length=100, description="Area of specialization e.g. Machine Learning")
18
+ experience_years: Optional[int] = Field(None, ge=0, description="Years of professional experience")
19
+ joining_date: Optional[datetime] = Field(None, description="Date of joining the institution")
20
+
21
+
22
+ class TeacherUpdate(BaseModel):
23
+ employee_id: Optional[str] = Field(None, min_length=3, max_length=30)
24
+ first_name: Optional[str] = Field(None, min_length=1, max_length=100)
25
+ last_name: Optional[str] = Field(None, min_length=1, max_length=100)
26
+ department_id: Optional[str] = Field(None)
27
+ designation_id: Optional[str] = Field(None)
28
+ phone: Optional[str] = Field(None, max_length=20)
29
+ qualification: Optional[str] = Field(None, max_length=100)
30
+ specialization: Optional[str] = Field(None, max_length=100)
31
+ experience_years: Optional[int] = Field(None, ge=0)
32
+ joining_date: Optional[datetime] = Field(None)
33
+
34
+
35
+ class TeacherResponse(BaseModel):
36
+ model_config = ConfigDict(from_attributes=True)
37
+
38
+ id: str = Field(..., description="Unique UUID of the teacher record")
39
+ user_id: str = Field(..., description="Mapped User UUID")
40
+ email: str = Field(..., description="Email address associated with the user profile")
41
+ employee_id: str = Field(..., description="Unique employee ID")
42
+ first_name: str = Field(..., description="Teacher first name")
43
+ last_name: str = Field(..., description="Teacher last name")
44
+ department_id: str = Field(..., description="Raw department UUID")
45
+ designation_id: str = Field(..., description="Raw designation UUID")
46
+ department: str = Field(..., description="Resolved department name")
47
+ designation: str = Field(..., description="Resolved designation name")
48
+ phone: Optional[str] = Field(None, description="Contact phone number")
49
+ qualification: Optional[str] = Field(None, description="Academic qualification")
50
+ specialization: Optional[str] = Field(None, description="Area of specialization")
51
+ experience_years: Optional[int] = Field(None, description="Years of professional experience")
52
+ joining_date: Optional[datetime] = Field(None, description="Joining date")
53
+
54
+
55
+ class SessionStart(BaseModel):
56
+ academic_class_id: str = Field(..., description="Target Class UUID for this session")
57
+ duration_minutes: int = Field(10, ge=1, le=180, description="Session validity window in minutes")
58
+
59
+
60
+ class SessionResponse(BaseModel):
61
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
62
+
63
+ id: str = Field(..., description="Unique UUID of the active session")
64
+ academic_class_id: str = Field(..., alias="academicClassId", description="Associated Class UUID")
65
+ start_time: datetime = Field(..., alias="startTime", description="Timestamp when the session opened")
66
+ end_time: datetime = Field(..., alias="endTime", description="Timestamp when the session will close")
67
+ is_active: bool = Field(..., alias="isActive", description="Current state of the session")
68
+
69
+
70
+ class GeofenceUpsert(BaseModel):
71
+ latitude: float = Field(..., description="GPS Latitude coordinate")
72
+ longitude: float = Field(..., description="GPS Longitude coordinate")
73
+ radius_meters: float = Field(..., gt=0.0, description="Geofence boundary radius in meters")
74
+
75
+
76
+ class GeofenceResponse(BaseModel):
77
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
78
+
79
+ id: str = Field(..., description="Unique UUID of the Geofence record")
80
+ academic_class_id: str = Field(..., alias="academicClassId", description="Class ID")
81
+ latitude: float = Field(..., description="Latitude coordinate")
82
+ longitude: float = Field(..., description="Longitude coordinate")
83
+ radius_meters: float = Field(..., alias="radiusMeters", description="Radius in meters")
84
+ created_at: datetime = Field(..., alias="createdAt", description="Timestamp created")
85
+ updated_at: datetime = Field(..., alias="updatedAt", description="Timestamp updated")
86
+
87
+
88
+ class AcademicClassWithGeofenceResponse(BaseModel):
89
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
90
+
91
+ id: str = Field(..., description="Unique UUID of the Academic Class")
92
+ name: str = Field(..., description="Class name")
93
+ subject: str = Field(..., description="Resolved subject name")
94
+ teacher_id: str = Field(..., alias="teacherId", description="Teacher ID")
95
+ geofence: Optional[GeofenceResponse] = Field(None, description="Class geofence configuration")
96
+
97
+
98
+ class StudentRosterItem(BaseModel):
99
+ student_id: str = Field(..., description="Student UUID")
100
+ enrollment_number: str = Field(..., description="Student enrollment number")
101
+ full_name: str = Field(..., description="Student full name (first + last)")
102
+ email: str = Field(..., description="Student email address")
103
+ status: str = Field(..., description="Attendance status (Present, Flagged, Absent)")
104
+ final_score: float = Field(..., description="Final calculated AI attendance score")
105
+ marked_at: Optional[datetime] = Field(None, description="Timestamp attendance was registered")
106
+
107
+
108
+ class SessionAttendanceResponse(BaseModel):
109
+ session_id: str = Field(..., description="Session UUID")
110
+ class_name: str = Field(..., description="Class name")
111
+ roster: list[StudentRosterItem] = Field(..., description="Enrolled roster list details")
112
+
113
+
114
+ class SessionTrendItem(BaseModel):
115
+ session_id: str = Field(..., description="Session UUID")
116
+ session_name: str = Field(..., description="Display name of session")
117
+ attendance_percentage: float = Field(..., description="Attendance percentage for session")
118
+
119
+
120
+ class ClassStatsResponse(BaseModel):
121
+ class_id: str = Field(..., description="Class UUID")
122
+ total_sessions: int = Field(..., description="Total sessions held for class")
123
+ total_students: int = Field(..., description="Total student count enrolled in class")
124
+ overall_attendance_percentage: float = Field(..., description="Overall class attendance percentage")
125
+ history: list[SessionTrendItem] = Field(default_factory=list, description="Chronological list of sessions with stats")
126
+
127
+
128
+ class AttendanceManualOverride(BaseModel):
129
+ student_id: str = Field(..., description="Student UUID to override")
130
+ status: str = Field(..., description="Target status (Present or Absent)")
131
+
132
+
133
+ class SessionWithClassResponse(BaseModel):
134
+ model_config = ConfigDict(from_attributes=True, populate_by_name=True)
135
+
136
+ id: str = Field(..., description="Unique UUID of the session")
137
+ academic_class_id: str = Field(..., alias="academicClassId", description="Associated Class UUID")
138
+ class_name: str = Field(..., description="Class Name")
139
+ subject: str = Field(..., description="Resolved subject name")
140
+ start_time: datetime = Field(..., alias="startTime", description="Timestamp when session opened")
141
+ end_time: datetime = Field(..., alias="endTime", description="Timestamp when session closed")
142
+ is_active: bool = Field(..., alias="isActive", description="Current state of session")
143
+
144
+
145
+ class BulkAttendanceRecord(BaseModel):
146
+ student_id: str = Field(..., description="Student UUID")
147
+ status: Literal["Present", "Absent"] = Field(..., description="Attendance status to set")
148
+
149
+
150
+ class BulkMarkRequest(BaseModel):
151
+ records: list[BulkAttendanceRecord] = Field(..., min_length=1, description="List of student attendance records")
152
+
153
+
154
+ class AbsentStudentItem(BaseModel):
155
+ student_id: str = Field(..., description="Student UUID")
156
+ enrollment_number: str = Field(..., description="Student enrollment number")
157
+ full_name: str = Field(..., description="Student full name")
158
+ email: str = Field(..., description="Student email address")
159
+
160
+
161
+ class DeviceChangeResponse(BaseModel):
162
+ id: str = Field(..., description="Device Change Request UUID")
163
+ student_id: str = Field(..., description="Student UUID")
164
+ student_name: str = Field(..., description="Student full name")
165
+ enrollment_number: str = Field(..., description="Student enrollment number")
166
+ new_device_uuid: str = Field(..., description="New device UUID requested")
167
+ reason: Optional[str] = Field(None, description="Reason for device change")
168
+ status: str = Field(..., description="Request status (PENDING, APPROVED, REJECTED)")
169
+ approved_by: Optional[str] = Field(None, description="UUID of approver")
170
+ created_at: datetime = Field(..., description="Creation timestamp")
171
+ updated_at: datetime = Field(..., description="Last update timestamp")
172
+
173
+
174
+ class DeviceChangeApprove(BaseModel):
175
+ status: Literal["APPROVED", "REJECTED"] = Field(..., description="Approval status decision")
176
+
backend/app/services/absentee_scanner.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from typing import List, Dict, Any
3
+
4
+ import pandas as pd
5
+ from sklearn.ensemble import IsolationForest
6
+
7
+ from app.core.logging_config import get_logger
8
+
9
+ logger = get_logger("app.ai.scanner")
10
+
11
+ _REQUIRED_COLS = {'student_id', 'status', 'day_of_week'}
12
+
13
+
14
+ def _run_isolation_forest(attendance_records: List[Dict[str, Any]], contamination: float) -> List[Dict[str, Any]]:
15
+ try:
16
+ if not attendance_records:
17
+ return []
18
+
19
+ df = pd.DataFrame(attendance_records)
20
+ if not _REQUIRED_COLS.issubset(df.columns):
21
+ logger.error("Missing required columns in attendance records. Required: %s", _REQUIRED_COLS)
22
+ return []
23
+
24
+ absences = df[df['status'] == 'Absent']
25
+ if absences.empty:
26
+ return []
27
+
28
+ profile = absences.groupby('student_id').size().reset_index(name='total_absences')
29
+ day_absences = pd.crosstab(absences['student_id'], absences['day_of_week']).reset_index()
30
+ profile = pd.merge(profile, day_absences, on='student_id', how='left').fillna(0)
31
+
32
+ features = profile.drop(columns=['student_id'])
33
+ model = IsolationForest(n_estimators=100, contamination=contamination, random_state=42)
34
+ model.fit(features)
35
+
36
+ profile['pred'] = model.predict(features)
37
+ profile['anomaly_score'] = -model.decision_function(features)
38
+
39
+ flagged = profile[profile['pred'] == -1].copy()
40
+ flagged = flagged.sort_values(by='total_absences', ascending=False).drop(columns=['pred'])
41
+ return flagged.to_dict(orient='records')
42
+
43
+ except Exception as e:
44
+ logger.error("Error in IsolationForest absentee scan: %s", e, exc_info=True)
45
+ return []
46
+
47
+
48
+ async def run_absentee_scan(attendance_records: List[Dict[str, Any]], contamination: float = 0.10) -> List[Dict[str, Any]]:
49
+ try:
50
+ flagged = await asyncio.to_thread(_run_isolation_forest, attendance_records, contamination)
51
+ if flagged:
52
+ logger.info("Absentee scan: %d at-risk students", len(flagged))
53
+ return flagged
54
+ except Exception as e:
55
+ logger.error("Failed to run async absentee scan wrapper: %s", e, exc_info=True)
56
+ return []
backend/app/services/admin_service.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+
3
+ from prisma.models import Department, AuditLog, Subject, Classroom, Designation
4
+
5
+ from app.core.security import hash_password
6
+ from app.db.client import db
7
+ from app.repositories.user_repo import UserRepository
8
+ from app.repositories.student_repo import StudentRepository
9
+ from app.repositories.teacher_repo import TeacherRepository
10
+ from app.repositories.class_repo import ClassRepository
11
+ from app.repositories.enrollment_repo import EnrollmentRepository
12
+ from app.schemas.student import StudentCreate, StudentResponse
13
+ from app.schemas.teacher import TeacherCreate, TeacherResponse
14
+ from app.schemas.admin import ClassCreate, ClassResponse
15
+
16
+
17
+ class AdminService:
18
+ def __init__(self) -> None:
19
+ self.user_repo = UserRepository()
20
+ self.student_repo = StudentRepository()
21
+ self.teacher_repo = TeacherRepository()
22
+ self.class_repo = ClassRepository()
23
+ self.enrollment_repo = EnrollmentRepository()
24
+
25
+ @staticmethod
26
+ async def _log_action(event_type: str, severity: str, actor: str, target: str, description: str, ip: Optional[str] = None) -> None:
27
+ await db.auditlog.create(data={
28
+ "eventType": event_type,
29
+ "severity": severity,
30
+ "actor": actor,
31
+ "target": target,
32
+ "description": description,
33
+ "ip": ip,
34
+ })
35
+
36
+ # --- Students ---
37
+
38
+ async def create_student(self, data: StudentCreate, actor: str = "system", ip: Optional[str] = None) -> StudentResponse:
39
+ if data.department_id:
40
+ if not await db.department.find_unique(where={"id": data.department_id}):
41
+ raise ValueError("Department not found.")
42
+
43
+ user = await db.user.create(data={
44
+ "email": data.email, "hashedPassword": hash_password(data.password), "role": "STUDENT",
45
+ })
46
+ student = await db.student.create(
47
+ data={k: v for k, v in {
48
+ "userId": user.id, "enrollmentNumber": data.enrollment_number,
49
+ "firstName": data.first_name, "lastName": data.last_name,
50
+ "phone": data.phone, "gender": data.gender, "dateOfBirth": data.date_of_birth,
51
+ "semester": data.semester, "batch": data.batch, "departmentId": data.department_id,
52
+ }.items() if v is not None},
53
+ include={"department": True},
54
+ )
55
+ await self._log_action("CREATE_STUDENT", "INFO", actor, student.id, f"Created student {data.email}", ip)
56
+ return StudentResponse(
57
+ id=student.id, user_id=user.id, enrollment_number=student.enrollmentNumber, email=user.email,
58
+ first_name=student.firstName, last_name=student.lastName, phone=student.phone,
59
+ gender=student.gender, date_of_birth=student.dateOfBirth,
60
+ department_id=student.departmentId,
61
+ department_name=student.department.name if student.department else None,
62
+ semester=student.semester, batch=student.batch,
63
+ )
64
+
65
+ async def get_all_students(self) -> List[StudentResponse]:
66
+ students = await db.student.find_many(include={"user": True, "department": True})
67
+ return [
68
+ StudentResponse(
69
+ id=s.id, user_id=s.userId, enrollment_number=s.enrollmentNumber,
70
+ email=s.user.email if s.user else "", first_name=s.firstName,
71
+ last_name=s.lastName, phone=s.phone, gender=s.gender,
72
+ date_of_birth=s.dateOfBirth, department_id=s.departmentId,
73
+ department_name=s.department.name if s.department else None,
74
+ semester=s.semester, batch=s.batch,
75
+ )
76
+ for s in students
77
+ ]
78
+
79
+ async def update_student(self, id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> StudentResponse:
80
+ mapping = {
81
+ "enrollment_number": "enrollmentNumber", "first_name": "firstName", "last_name": "lastName",
82
+ "phone": "phone", "gender": "gender", "date_of_birth": "dateOfBirth",
83
+ "semester": "semester", "batch": "batch", "department_id": "departmentId",
84
+ }
85
+ update_data = {mapping[k]: v for k, v in data.items() if k in mapping}
86
+ student = await db.student.update(where={"id": id}, data=update_data, include={"user": True, "department": True})
87
+ await self._log_action("UPDATE_STUDENT", "INFO", actor, id, f"Updated student {student.user.email if student.user else id}", ip)
88
+ return StudentResponse(
89
+ id=student.id, user_id=student.userId, enrollment_number=student.enrollmentNumber,
90
+ email=student.user.email if student.user else "", first_name=student.firstName,
91
+ last_name=student.lastName, phone=student.phone, gender=student.gender,
92
+ date_of_birth=student.dateOfBirth, department_id=student.departmentId,
93
+ department_name=student.department.name if student.department else None,
94
+ semester=student.semester, batch=student.batch,
95
+ )
96
+
97
+ # --- Teachers ---
98
+
99
+ async def create_teacher(self, data: TeacherCreate, actor: str = "system", ip: Optional[str] = None) -> TeacherResponse:
100
+ if not await db.department.find_unique(where={"id": data.department_id}):
101
+ raise ValueError(f"Department with id '{data.department_id}' not found.")
102
+ if not await db.designation.find_unique(where={"id": data.designation_id}):
103
+ raise ValueError(f"Designation with id '{data.designation_id}' not found.")
104
+
105
+ user = await db.user.create(data={
106
+ "email": data.email, "hashedPassword": hash_password(data.password), "role": "TEACHER",
107
+ })
108
+ teacher = await db.teacher.create(
109
+ data={k: v for k, v in {
110
+ "userId": user.id, "employeeId": data.employee_id,
111
+ "firstName": data.first_name, "lastName": data.last_name,
112
+ "departmentId": data.department_id, "designationId": data.designation_id,
113
+ "phone": data.phone, "qualification": data.qualification,
114
+ "specialization": data.specialization, "experienceYears": data.experience_years,
115
+ "joiningDate": data.joining_date,
116
+ }.items() if v is not None},
117
+ include={"department": True, "designation": True},
118
+ )
119
+ await self._log_action("CREATE_TEACHER", "INFO", actor, teacher.id, f"Created teacher {data.email}", ip)
120
+ return TeacherResponse(
121
+ id=teacher.id, user_id=user.id, email=user.email, employee_id=teacher.employeeId,
122
+ first_name=teacher.firstName, last_name=teacher.lastName,
123
+ department_id=teacher.departmentId, designation_id=teacher.designationId,
124
+ department=teacher.department.name, designation=teacher.designation.name,
125
+ phone=teacher.phone, qualification=teacher.qualification,
126
+ specialization=teacher.specialization, experience_years=teacher.experienceYears,
127
+ joining_date=teacher.joiningDate,
128
+ )
129
+
130
+ async def get_all_teachers(self) -> List[TeacherResponse]:
131
+ teachers = await db.teacher.find_many(include={"user": True, "department": True, "designation": True})
132
+ return [
133
+ TeacherResponse(
134
+ id=t.id, user_id=t.userId, email=t.user.email if t.user else "",
135
+ employee_id=t.employeeId, first_name=t.firstName, last_name=t.lastName,
136
+ department_id=t.departmentId, designation_id=t.designationId,
137
+ department=t.department.name if t.department else "",
138
+ designation=t.designation.name if t.designation else "",
139
+ phone=t.phone, qualification=t.qualification, specialization=t.specialization,
140
+ experience_years=t.experienceYears, joining_date=t.joiningDate,
141
+ )
142
+ for t in teachers
143
+ ]
144
+
145
+ async def update_teacher(self, id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> TeacherResponse:
146
+ mapping = {
147
+ "employee_id": "employeeId", "first_name": "firstName", "last_name": "lastName",
148
+ "department_id": "departmentId", "designation_id": "designationId", "phone": "phone",
149
+ "qualification": "qualification", "specialization": "specialization",
150
+ "experience_years": "experienceYears", "joining_date": "joiningDate",
151
+ }
152
+ update_data = {mapping[k]: v for k, v in data.items() if k in mapping}
153
+ teacher = await db.teacher.update(where={"id": id}, data=update_data, include={"user": True, "department": True, "designation": True})
154
+ await self._log_action("UPDATE_TEACHER", "INFO", actor, id, f"Updated teacher {teacher.user.email if teacher.user else id}", ip)
155
+ return TeacherResponse(
156
+ id=teacher.id, user_id=teacher.userId, email=teacher.user.email if teacher.user else "",
157
+ employee_id=teacher.employeeId, first_name=teacher.firstName, last_name=teacher.lastName,
158
+ department_id=teacher.departmentId, designation_id=teacher.designationId,
159
+ department=teacher.department.name if teacher.department else "",
160
+ designation=teacher.designation.name if teacher.designation else "",
161
+ phone=teacher.phone, qualification=teacher.qualification,
162
+ specialization=teacher.specialization, experience_years=teacher.experienceYears,
163
+ joining_date=teacher.joiningDate,
164
+ )
165
+
166
+ # --- Classes ---
167
+
168
+ async def create_class(self, data: ClassCreate, actor: str = "system", ip: Optional[str] = None) -> ClassResponse:
169
+ if not await self.teacher_repo.get_by_id(data.teacher_id):
170
+ raise ValueError("Teacher profile not found.")
171
+ if not await db.subject.find_unique(where={"id": data.subject_id}):
172
+ raise ValueError(f"Subject with id '{data.subject_id}' not found.")
173
+ if data.classroom_id and not await db.classroom.find_unique(where={"id": data.classroom_id}):
174
+ raise ValueError(f"Classroom with id '{data.classroom_id}' not found.")
175
+
176
+ cls = await db.academicclass.create(
177
+ data={k: v for k, v in {
178
+ "name": data.name, "teacherId": data.teacher_id, "subjectId": data.subject_id,
179
+ "classroomId": data.classroom_id, "semester": data.semester,
180
+ "batch": data.batch, "maxStudents": data.max_students,
181
+ }.items() if v is not None},
182
+ include={"subject": True, "classroom": True, "enrollments": True},
183
+ )
184
+ await self._log_action("CREATE_CLASS", "INFO", actor, cls.id, f"Created class {data.name}", ip)
185
+ return ClassResponse(
186
+ id=cls.id, name=cls.name, subject_name=cls.subject.name, subject_code=cls.subject.code,
187
+ teacherId=cls.teacherId, classroom_name=cls.classroom.name if cls.classroom else None,
188
+ semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents,
189
+ enrolled_count=len(cls.enrollments) if cls.enrollments else 0,
190
+ enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [],
191
+ )
192
+
193
+ async def get_all_classes(self) -> List[ClassResponse]:
194
+ classes = await db.academicclass.find_many(include={"subject": True, "classroom": True, "enrollments": True})
195
+ return [
196
+ ClassResponse(
197
+ id=c.id, name=c.name, subject_name=c.subject.name if c.subject else "",
198
+ subject_code=c.subject.code if c.subject else "", teacherId=c.teacherId,
199
+ classroom_name=c.classroom.name if c.classroom else None,
200
+ semester=c.semester, batch=c.batch, max_students=c.maxStudents,
201
+ enrolled_count=len(c.enrollments) if c.enrollments else 0,
202
+ enrolled_student_ids=[e.studentId for e in c.enrollments] if c.enrollments else [],
203
+ )
204
+ for c in classes
205
+ ]
206
+
207
+ async def update_class(self, class_id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> ClassResponse:
208
+ renames = {"subject_id": "subjectId", "classroom_id": "classroomId", "teacher_id": "teacherId"}
209
+ for old, new in renames.items():
210
+ if old in data:
211
+ data[new] = data.pop(old)
212
+ cls = await db.academicclass.update(where={"id": class_id}, data=data, include={"subject": True, "classroom": True, "enrollments": True})
213
+ await self._log_action("UPDATE_CLASS", "INFO", actor, class_id, f"Updated class {cls.name}", ip)
214
+ return ClassResponse(
215
+ id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "",
216
+ subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId,
217
+ classroom_name=cls.classroom.name if cls.classroom else None,
218
+ semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents,
219
+ enrolled_count=len(cls.enrollments) if cls.enrollments else 0,
220
+ enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [],
221
+ )
222
+
223
+ async def assign_teacher(self, class_id: str, teacher_id: str, actor: str = "system", ip: Optional[str] = None) -> ClassResponse:
224
+ if not await self.class_repo.get_by_id(class_id):
225
+ raise ValueError("Academic class not found.")
226
+ if not await self.teacher_repo.get_by_id(teacher_id):
227
+ raise ValueError("Teacher profile not found.")
228
+ cls = await db.academicclass.update(where={"id": class_id}, data={"teacherId": teacher_id}, include={"subject": True, "classroom": True, "enrollments": True})
229
+ await self._log_action("ASSIGN_TEACHER", "INFO", actor, class_id, f"Assigned teacher {teacher_id} to class {cls.name}", ip)
230
+ return ClassResponse(
231
+ id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "",
232
+ subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId,
233
+ classroom_name=cls.classroom.name if cls.classroom else None,
234
+ semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents,
235
+ enrolled_count=len(cls.enrollments) if cls.enrollments else 0,
236
+ enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [],
237
+ )
238
+
239
+ async def enroll_students(self, class_id: str, student_ids: List[str], actor: str = "system", ip: Optional[str] = None) -> int:
240
+ if not await self.class_repo.get_by_id(class_id):
241
+ raise ValueError("Academic class not found.")
242
+ count = 0
243
+ for sid in student_ids:
244
+ if await self.student_repo.get_by_id(sid):
245
+ existing = await db.enrollment.find_first(
246
+ where={"studentId": sid, "academicClassId": class_id}
247
+ )
248
+ if not existing:
249
+ await self.enrollment_repo.enroll_student(sid, class_id)
250
+ count += 1
251
+ await self._log_action("ENROLL_STUDENTS", "INFO", actor, class_id, f"Enrolled {count} student(s) in class", ip)
252
+ return count
253
+
254
+ # --- Master Data Helpers ---
255
+
256
+ @staticmethod
257
+ async def _validate_delete(entity_name: str, record, ref_field: str, ref_table_name: str):
258
+ if not record:
259
+ raise ValueError(f"{entity_name} not found.")
260
+ ref_count = await getattr(db, ref_table_name).count(where={ref_field: record.id})
261
+ if ref_count > 0:
262
+ raise ValueError(f"Cannot delete {entity_name.lower()} because it is currently assigned.")
263
+
264
+ # --- Departments ---
265
+
266
+ async def get_all_departments(self) -> List[Department]:
267
+ return await db.department.find_many()
268
+
269
+ async def get_department_by_id(self, id: str) -> Optional[Department]:
270
+ return await db.department.find_unique(where={"id": id})
271
+
272
+ async def create_department(self, name: str, code: str, head: Optional[str] = None, description: Optional[str] = None) -> Department:
273
+ return await db.department.create(data={"name": name, "code": code, "head": head, "description": description})
274
+
275
+ async def update_department(self, id: str, data: dict) -> Department:
276
+ return await db.department.update(where={"id": id}, data=data)
277
+
278
+ async def delete_department(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None:
279
+ dept = await db.department.find_unique(where={"id": id})
280
+ await self._validate_delete("Department", dept, "departmentId", "teacher")
281
+ await self._validate_delete("Department", dept, "departmentId", "student")
282
+ await db.department.delete(where={"id": id})
283
+ await self._log_action("DELETE_DEPARTMENT", "WARNING", actor, id, f"Deleted department {dept.name if dept else id}", ip)
284
+
285
+ # --- Subjects ---
286
+
287
+ async def get_all_subjects(self) -> List[Subject]:
288
+ return await db.subject.find_many()
289
+
290
+ async def get_subject_by_id(self, id: str) -> Optional[Subject]:
291
+ return await db.subject.find_unique(where={"id": id})
292
+
293
+ async def create_subject(self, name: str, code: str, description: Optional[str] = None) -> Subject:
294
+ return await db.subject.create(data={"name": name, "code": code, "description": description})
295
+
296
+ async def update_subject(self, id: str, data: dict) -> Subject:
297
+ return await db.subject.update(where={"id": id}, data=data)
298
+
299
+ async def delete_subject(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None:
300
+ sub = await db.subject.find_unique(where={"id": id})
301
+ await self._validate_delete("Subject", sub, "subjectId", "academicclass")
302
+ await db.subject.delete(where={"id": id})
303
+ await self._log_action("DELETE_SUBJECT", "WARNING", actor, id, f"Deleted subject {sub.name if sub else id}", ip)
304
+
305
+ # --- Classrooms ---
306
+
307
+ async def get_all_classrooms(self) -> List[Classroom]:
308
+ return await db.classroom.find_many()
309
+
310
+ async def get_classroom_by_id(self, id: str) -> Optional[Classroom]:
311
+ return await db.classroom.find_unique(where={"id": id})
312
+
313
+ async def create_classroom(self, name: str, building: Optional[str] = None, capacity: Optional[int] = None) -> Classroom:
314
+ return await db.classroom.create(data={"name": name, "building": building, "capacity": capacity})
315
+
316
+ async def update_classroom(self, id: str, data: dict) -> Classroom:
317
+ return await db.classroom.update(where={"id": id}, data=data)
318
+
319
+ async def delete_classroom(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None:
320
+ classroom = await db.classroom.find_unique(where={"id": id})
321
+ await self._validate_delete("Classroom", classroom, "classroomId", "academicclass")
322
+ await db.classroom.delete(where={"id": id})
323
+ await self._log_action("DELETE_CLASSROOM", "WARNING", actor, id, f"Deleted classroom {classroom.name if classroom else id}", ip)
324
+
325
+ # --- Designations ---
326
+
327
+ async def get_all_designations(self) -> List[Designation]:
328
+ return await db.designation.find_many()
329
+
330
+ async def get_designation_by_id(self, id: str) -> Optional[Designation]:
331
+ return await db.designation.find_unique(where={"id": id})
332
+
333
+ async def create_designation(self, name: str, code: str, description: Optional[str] = None) -> Designation:
334
+ return await db.designation.create(data={"name": name, "code": code, "description": description})
335
+
336
+ async def update_designation(self, id: str, data: dict) -> Designation:
337
+ return await db.designation.update(where={"id": id}, data=data)
338
+
339
+ async def delete_designation(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None:
340
+ desig = await db.designation.find_unique(where={"id": id})
341
+ await self._validate_delete("Designation", desig, "designationId", "teacher")
342
+ await db.designation.delete(where={"id": id})
343
+ await self._log_action("DELETE_DESIGNATION", "WARNING", actor, id, f"Deleted designation {desig.name if desig else id}", ip)
344
+
345
+ # --- Misc ---
346
+
347
+ async def get_audit_logs(self) -> List[AuditLog]:
348
+ return await db.auditlog.find_many(order={"timestamp": "desc"})
349
+
350
+ async def get_stats(self) -> dict:
351
+ return {
352
+ "studentCount": await db.student.count(),
353
+ "teacherCount": await db.teacher.count(),
354
+ "classCount": await db.academicclass.count(),
355
+ }
356
+
357
+ async def reset_user_password(self, user_id: str, new_password: str, actor: str = "system", ip: Optional[str] = None) -> None:
358
+ if not await db.user.find_unique(where={"id": user_id}):
359
+ raise ValueError("User not found.")
360
+ await db.user.update(where={"id": user_id}, data={"hashedPassword": hash_password(new_password)})
361
+ await self._log_action("RESET_PASSWORD", "WARNING", actor, user_id, "Reset user password", ip)
backend/app/services/ai_orchestrator.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import shutil
4
+ import threading
5
+ from typing import List, Optional, Tuple
6
+
7
+ import cv2
8
+ import numpy as np
9
+ import tensorflow as tf
10
+ from deepface import DeepFace
11
+ from huggingface_hub import hf_hub_download
12
+ from tensorflow.keras.applications.mobilenet import preprocess_input
13
+
14
+ from app.core.logging_config import get_logger
15
+
16
+ logger = get_logger("app.ai")
17
+
18
+ os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3')
19
+ os.environ.setdefault('TF_ENABLE_ONEDNN_OPTS', '0')
20
+ os.environ.setdefault('CUDA_VISIBLE_DEVICES', '-1')
21
+
22
+ BASE_MODELS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../models"))
23
+ LIVENESS_REPO = "prathamrajbhar/smart-attendance-liveness-detection"
24
+ BACKGROUND_REPO = "prathamrajbhar/smart-attendance-background-validation"
25
+ LIVENESS_FILENAME = "liveness_mobilenet_v2.h5"
26
+ BACKGROUND_FILENAME = "background_mobilenet_v1.h5"
27
+
28
+ LIVENESS_MODEL_PATH_V2 = os.path.join(BASE_MODELS_DIR, "liveness_detection", "liveness_mobilenet_v2.h5")
29
+ LIVENESS_MODEL_PATH_V1 = os.path.join(BASE_MODELS_DIR, "liveness_detection", "liveness_mobilenet_v1.h5")
30
+ BACKGROUND_MODEL_PATH = os.path.join(BASE_MODELS_DIR, "background_validation", "background_mobilenet_v1.h5")
31
+
32
+
33
+ def _ensure_model_downloaded(repo_id: str, filename: str, local_path: str) -> str:
34
+ if os.path.exists(local_path):
35
+ return local_path
36
+ logger.info("Downloading model: %s/%s", repo_id, filename)
37
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
38
+ try:
39
+ downloaded = hf_hub_download(repo_id=repo_id, filename=filename, token=os.environ.get("HF_TOKEN"))
40
+ shutil.copy(downloaded, local_path)
41
+ return local_path
42
+ except Exception as e:
43
+ logger.error("Failed to download model from Hugging Face: %s", e, exc_info=True)
44
+ raise RuntimeError(f"Could not load model {filename} from {repo_id}: {e}") from e
45
+
46
+
47
+ def _load_liveness_model(model_path: str) -> tf.keras.Model:
48
+ base = tf.keras.applications.MobileNetV2(input_shape=(224, 224, 3), include_top=False, weights=None)
49
+ x = base.output
50
+ x = tf.keras.layers.GlobalAveragePooling2D(name="global_average_pooling2d_3")(x)
51
+ x = tf.keras.layers.Dropout(0.001, name="dropout_3")(x)
52
+ outputs = tf.keras.layers.Dense(1, activation="sigmoid", name="dense_3")(x)
53
+ model = tf.keras.models.Model(inputs=base.input, outputs=outputs)
54
+ model.load_weights(model_path, by_name=True)
55
+ return model
56
+
57
+
58
+ _liveness_path = LIVENESS_MODEL_PATH_V2 if os.path.exists(LIVENESS_MODEL_PATH_V2) else (LIVENESS_MODEL_PATH_V1 if os.path.exists(LIVENESS_MODEL_PATH_V1) else LIVENESS_MODEL_PATH_V2)
59
+ _final_liveness_path = _ensure_model_downloaded(LIVENESS_REPO, LIVENESS_FILENAME, _liveness_path)
60
+ _final_background_path = _ensure_model_downloaded(BACKGROUND_REPO, BACKGROUND_FILENAME, BACKGROUND_MODEL_PATH)
61
+
62
+ liveness_model = _load_liveness_model(_final_liveness_path)
63
+ background_model = tf.keras.models.load_model(_final_background_path)
64
+
65
+ _liveness_lock = threading.Lock()
66
+ _background_lock = threading.Lock()
67
+ _deepface_lock = threading.Lock()
68
+
69
+
70
+ class AIOrchestrator:
71
+ def _detect_and_crop_face(self, img: np.ndarray) -> Tuple[Optional[np.ndarray], Optional[Tuple[int, int, int, int]]]:
72
+ try:
73
+ with _deepface_lock:
74
+ faces = DeepFace.extract_faces(img_path=img, detector_backend="opencv", enforce_detection=True)
75
+ if not faces:
76
+ return None, None
77
+ fa = faces[0]["facial_area"]
78
+ x, y, w, h = fa["x"], fa["y"], fa["w"], fa["h"]
79
+ return img[y:y+h, x:x+w], (x, y, w, h)
80
+ except Exception as e:
81
+ logger.warning("DeepFace face extraction failed: %s", e)
82
+ return None, None
83
+
84
+ @staticmethod
85
+ def _preprocess_liveness(face_crop: np.ndarray) -> np.ndarray:
86
+ return np.expand_dims((cv2.resize(face_crop, (224, 224)).astype(np.float32) / 127.5) - 1.0, axis=0)
87
+
88
+ @staticmethod
89
+ def _preprocess_background(img: np.ndarray) -> np.ndarray:
90
+ return preprocess_input(np.expand_dims(cv2.resize(img, (224, 224)), axis=0).astype(np.float32))
91
+
92
+ def _run_face_comparison(self, stored_embedding: List[float], live_img: np.ndarray) -> float:
93
+ if not stored_embedding or live_img is None:
94
+ return 0.0
95
+ try:
96
+ with _deepface_lock:
97
+ results = DeepFace.represent(img_path=live_img, model_name="Facenet", enforce_detection=False)
98
+ if not results:
99
+ return 0.0
100
+ vec_s = np.array(stored_embedding, dtype=np.float32)
101
+ vec_l = np.array(results[0]["embedding"], dtype=np.float32)
102
+ norm_s, norm_l = np.linalg.norm(vec_s), np.linalg.norm(vec_l)
103
+ if norm_s == 0.0 or norm_l == 0.0:
104
+ return 0.0
105
+ return max(0.0, min(1.0, float(np.dot(vec_s, vec_l) / (norm_s * norm_l))))
106
+ except Exception as e:
107
+ logger.error("DeepFace face comparison failed safely: %s", e, exc_info=True)
108
+ return 0.0
109
+
110
+ def _run_liveness_inference(self, face_crop: np.ndarray) -> float:
111
+ if face_crop is None:
112
+ return 0.0
113
+ with _liveness_lock:
114
+ return float(liveness_model.predict(self._preprocess_liveness(cv2.cvtColor(face_crop, cv2.COLOR_RGB2BGR)), verbose=0)[0][0])
115
+
116
+ def _run_background_inference(self, img: np.ndarray) -> float:
117
+ if img is None:
118
+ return 0.0
119
+ with _background_lock:
120
+ return float(background_model.predict(self._preprocess_background(img), verbose=0)[0][0])
121
+
122
+ def _run_embedding_extraction(self, image_path: str) -> List[float]:
123
+ try:
124
+ img = cv2.imread(image_path)
125
+ if img is None:
126
+ return []
127
+ with _deepface_lock:
128
+ results = DeepFace.represent(img_path=cv2.cvtColor(img, cv2.COLOR_BGR2RGB), model_name="Facenet", enforce_detection=True)
129
+ return [float(v) for v in results[0]["embedding"]] if results else []
130
+ except Exception as e:
131
+ logger.error("DeepFace face embedding extraction failed safely: %s", e, exc_info=True)
132
+ return []
133
+
134
+ async def extract_face_embedding(self, image_path: str) -> List[float]:
135
+ if not os.path.exists(image_path):
136
+ return []
137
+ try:
138
+ return await asyncio.to_thread(self._run_embedding_extraction, image_path)
139
+ except Exception as e:
140
+ logger.error("Face embedding extraction thread run failed: %s", e, exc_info=True)
141
+ return []
142
+
143
+ async def analyze_attendance(self, image_path: str, face_embedding: List[float]) -> dict:
144
+ if not os.path.exists(image_path):
145
+ logger.warning("Image path does not exist for attendance analysis: %s", image_path)
146
+ return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0}
147
+
148
+ def _load_and_crop():
149
+ img = cv2.imread(image_path)
150
+ if img is None:
151
+ return None, None, None
152
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
153
+ face_crop, _ = self._detect_and_crop_face(img_rgb)
154
+ return img_rgb, face_crop, _
155
+
156
+ try:
157
+ img_rgb, face_crop, _ = await asyncio.to_thread(_load_and_crop)
158
+ except Exception as e:
159
+ logger.error("Failed to load and crop image: %s", e, exc_info=True)
160
+ return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0}
161
+
162
+ if img_rgb is None:
163
+ return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0}
164
+
165
+ try:
166
+ face_score, liveness_score, bg_score = await asyncio.gather(
167
+ asyncio.to_thread(self._run_face_comparison, face_embedding, img_rgb),
168
+ asyncio.to_thread(self._run_liveness_inference, face_crop),
169
+ asyncio.to_thread(self._run_background_inference, img_rgb),
170
+ )
171
+ return {"face_score": face_score, "liveness_score": liveness_score, "background_score": bg_score}
172
+ except Exception as e:
173
+ logger.error("Concurrent AI inference failed: %s", e, exc_info=True)
174
+ return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0}
backend/app/services/attendance_service.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+
6
+ from prisma.models import Attendance
7
+
8
+ from app.core.config import settings
9
+ from app.core.logging_config import get_logger
10
+ from app.db.redis import get_redis
11
+ from app.repositories.attendance_repo import AttendanceRepository
12
+ from app.repositories.session_repo import SessionRepository
13
+ from app.repositories.geofence_repo import GeofenceRepository
14
+ from app.repositories.student_repo import StudentRepository
15
+ from app.repositories.class_repo import ClassRepository
16
+ from app.services.ai_orchestrator import AIOrchestrator
17
+ from app.services.system_config_service import SystemConfigService
18
+ from app.utils.geofencing import GPSCoordinate, calculate_haversine_distance, is_within_geofence
19
+ from app.api.ws import manager
20
+
21
+ logger = get_logger("app.attendance")
22
+
23
+
24
+ class CachedSession:
25
+ def __init__(self, id: str, is_active: bool, academic_class_id: str, end_time: datetime):
26
+ self.id = id
27
+ self.isActive = is_active
28
+ self.academicClassId = academic_class_id
29
+ self.endTime = end_time
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AttendanceSubmission:
34
+ student_id: str
35
+ session_id: str
36
+ latitude: float
37
+ longitude: float
38
+ accuracy: float
39
+ image_path: str | None = None
40
+
41
+
42
+ class AttendanceService:
43
+ def __init__(self) -> None:
44
+ self.attendance_repo = AttendanceRepository()
45
+ self.session_repo = SessionRepository()
46
+ self.geofence_repo = GeofenceRepository()
47
+ self.student_repo = StudentRepository()
48
+ self.class_repo = ClassRepository()
49
+ self.ai_orchestrator = AIOrchestrator()
50
+
51
+ async def mark_attendance(self, submission: AttendanceSubmission) -> Attendance:
52
+ session = None
53
+ redis_client = None
54
+ cache_key = f"session:{submission.session_id}"
55
+
56
+ try:
57
+ redis_client = get_redis()
58
+ cached = await redis_client.get(cache_key)
59
+ if cached:
60
+ data = json.loads(cached)
61
+ end_time_str = data["endTime"].replace("Z", "+00:00")
62
+ session = CachedSession(
63
+ id=data["id"], is_active=data["isActive"],
64
+ academic_class_id=data["academicClassId"],
65
+ end_time=datetime.fromisoformat(end_time_str),
66
+ )
67
+ except Exception:
68
+ logger.warning("Redis session cache read failed. Falling back to DB.")
69
+
70
+ if not session:
71
+ session = await self.session_repo.get_by_id(submission.session_id)
72
+ if not session:
73
+ raise ValueError("Attendance session is not active or not found.")
74
+
75
+ if redis_client:
76
+ try:
77
+ now = datetime.now(timezone.utc)
78
+ end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime
79
+ ttl = max(1, min(int((end - now).total_seconds()), 600))
80
+ await redis_client.setex(cache_key, ttl, json.dumps({
81
+ "id": session.id, "isActive": session.isActive,
82
+ "academicClassId": session.academicClassId,
83
+ "endTime": session.endTime.isoformat(),
84
+ }))
85
+ except Exception as e:
86
+ logger.warning("Redis session cache write failed: %s", e)
87
+
88
+ now = datetime.now(timezone.utc)
89
+ session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime
90
+ if not session.isActive or session_end <= now:
91
+ if session.isActive:
92
+ await self.session_repo.deactivate(session.id)
93
+ try:
94
+ await get_redis().delete(f"session:{session.id}")
95
+ except Exception:
96
+ pass
97
+ raise ValueError("Attendance session is not active or not found.")
98
+
99
+ config = await SystemConfigService().get_config()
100
+
101
+ geofence_missing = False
102
+ remarks = None
103
+
104
+ if config.isGpsVerificationEnabled:
105
+ geofence = await self.geofence_repo.get_by_class_id(session.academicClassId)
106
+ if not geofence:
107
+ logger.warning("Missing geofence for class %s (student %s)", session.academicClassId, submission.student_id)
108
+ geofence_missing = True
109
+ remarks = "Missing Geofence Data"
110
+ else:
111
+ student_coord = GPSCoordinate(submission.latitude, submission.longitude)
112
+ classroom_coord = GPSCoordinate(geofence.latitude, geofence.longitude)
113
+ is_inside = is_within_geofence(
114
+ student_coord=student_coord,
115
+ classroom_coord=classroom_coord,
116
+ base_radius=geofence.radiusMeters,
117
+ student_accuracy=submission.accuracy,
118
+ )
119
+ if not is_inside:
120
+ distance = calculate_haversine_distance(student_coord, classroom_coord)
121
+ effective_radius = geofence.radiusMeters + submission.accuracy
122
+ raise ValueError(
123
+ f"Student is outside geofence boundary by {distance - effective_radius:.1f}m. "
124
+ f"(Distance: {distance:.1f}m, Effective Allowed Radius: {effective_radius:.1f}m)"
125
+ )
126
+
127
+ if config.isFaceRecognitionEnabled:
128
+ face_embedding = await self.student_repo.get_face_embedding(submission.student_id)
129
+ if not face_embedding:
130
+ raise ValueError("Student face embedding is not registered.")
131
+ else:
132
+ face_embedding = []
133
+
134
+ existing = await self.attendance_repo.get_by_student_and_session(submission.student_id, submission.session_id)
135
+ if existing:
136
+ raise ValueError("Attendance already submitted for this session.")
137
+
138
+ if config.isFaceRecognitionEnabled or config.isAiBackgroundValidationEnabled:
139
+ if not submission.image_path:
140
+ raise ValueError("Image is required when verification is enabled.")
141
+ ai_results = await self.ai_orchestrator.analyze_attendance(submission.image_path, face_embedding)
142
+ else:
143
+ ai_results = {"face_score": 1.0, "liveness_score": 1.0, "background_score": 1.0}
144
+
145
+ if not config.isFaceRecognitionEnabled:
146
+ ai_results["face_score"] = 1.0
147
+ ai_results["liveness_score"] = 1.0
148
+
149
+ if not config.isAiBackgroundValidationEnabled:
150
+ ai_results["background_score"] = 1.0
151
+ final_score = (
152
+ settings.FACE_WEIGHT * ai_results["face_score"]
153
+ + settings.LIVENESS_WEIGHT * ai_results["liveness_score"]
154
+ + settings.BACKGROUND_WEIGHT * ai_results["background_score"]
155
+ )
156
+ status = "Flagged" if geofence_missing else ("Present" if final_score >= settings.PASS_THRESHOLD else "Flagged")
157
+
158
+ attendance_record = await self.attendance_repo.create({
159
+ "studentId": submission.student_id, "sessionId": submission.session_id,
160
+ "status": status,
161
+ "faceScore": ai_results["face_score"], "livenessScore": ai_results["liveness_score"],
162
+ "backgroundScore": ai_results["background_score"], "finalAiScore": final_score,
163
+ "gpsLatitude": submission.latitude, "gpsLongitude": submission.longitude,
164
+ "remarks": remarks,
165
+ })
166
+
167
+ try:
168
+ msg = {"type": "attendance_updated", "session_id": submission.session_id, "status": status}
169
+ await manager.send_personal_message(msg, student_id=submission.student_id)
170
+ msg["student_id"] = submission.student_id
171
+ await manager.broadcast_to_teachers(msg)
172
+ except Exception as e:
173
+ logger.warning("WebSocket broadcast failed: %s", e)
174
+
175
+ if status == "Flagged":
176
+ try:
177
+ from app.services.notification_service import notify_student_attendance_flagged
178
+ student = await self.student_repo.get_by_id(submission.student_id)
179
+ if student and student.fcmToken:
180
+ ac = await self.class_repo.get_by_id(session.academicClassId)
181
+ class_name = ac.name if ac else "your class"
182
+ await notify_student_attendance_flagged(student.fcmToken, student.firstName or "Student", class_name, attendance_record.id)
183
+ except Exception as e:
184
+ logger.warning("FCM notification failed: %s", e)
185
+
186
+ try:
187
+ from app.services.gamification_service import GamificationService
188
+ await GamificationService().update_streak(submission.student_id, status)
189
+ except Exception as e:
190
+ logger.warning("Streak update failed: %s", e)
191
+
192
+ if status == "Present" and submission.image_path and os.path.exists(submission.image_path):
193
+ try:
194
+ os.remove(submission.image_path)
195
+ except Exception as e:
196
+ logger.error("Failed to remove temp image %s: %s", submission.image_path, e, exc_info=True)
197
+
198
+ return attendance_record
199
+
200
+ async def analyze_attendance(self, submission: AttendanceSubmission) -> dict:
201
+ """Run all validation + AI scoring but do NOT save the record.
202
+
203
+ Returns a dict with scores, predicted status, and a short-lived
204
+ review_token that can be passed to mark_attendance to skip re-running AI.
205
+ """
206
+ from datetime import timedelta
207
+ from app.core.security import create_access_token
208
+
209
+ # ── Session validation ──────────────────────────────────────────────
210
+ session = None
211
+ redis_client = None
212
+ cache_key = f"session:{submission.session_id}"
213
+
214
+ try:
215
+ redis_client = get_redis()
216
+ cached = await redis_client.get(cache_key)
217
+ if cached:
218
+ data = json.loads(cached)
219
+ end_time_str = data["endTime"].replace("Z", "+00:00")
220
+ session = CachedSession(
221
+ id=data["id"], is_active=data["isActive"],
222
+ academic_class_id=data["academicClassId"],
223
+ end_time=datetime.fromisoformat(end_time_str),
224
+ )
225
+ except Exception:
226
+ logger.warning("Redis session cache read failed. Falling back to DB.")
227
+
228
+ if not session:
229
+ session = await self.session_repo.get_by_id(submission.session_id)
230
+ if not session:
231
+ raise ValueError("Attendance session is not active or not found.")
232
+
233
+ now = datetime.now(timezone.utc)
234
+ session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime
235
+ if not session.isActive or session_end <= now:
236
+ if session.isActive:
237
+ await self.session_repo.deactivate(session.id)
238
+ try:
239
+ await get_redis().delete(f"session:{session.id}")
240
+ except Exception:
241
+ pass
242
+ raise ValueError("Attendance session is not active or not found.")
243
+
244
+ config = await SystemConfigService().get_config()
245
+
246
+ # ── Geofence check ──────────────────────────────────────────────────
247
+ geofence_missing = False
248
+ if config.isGpsVerificationEnabled:
249
+ geofence = await self.geofence_repo.get_by_class_id(session.academicClassId)
250
+ if not geofence:
251
+ logger.warning("Missing geofence for class %s (student %s)", session.academicClassId, submission.student_id)
252
+ geofence_missing = True
253
+ else:
254
+ student_coord = GPSCoordinate(submission.latitude, submission.longitude)
255
+ classroom_coord = GPSCoordinate(geofence.latitude, geofence.longitude)
256
+ is_inside = is_within_geofence(
257
+ student_coord=student_coord,
258
+ classroom_coord=classroom_coord,
259
+ base_radius=geofence.radiusMeters,
260
+ student_accuracy=submission.accuracy,
261
+ )
262
+ if not is_inside:
263
+ distance = calculate_haversine_distance(student_coord, classroom_coord)
264
+ effective_radius = geofence.radiusMeters + submission.accuracy
265
+ raise ValueError(
266
+ f"Student is outside geofence boundary by {distance - effective_radius:.1f}m. "
267
+ f"(Distance: {distance:.1f}m, Effective Allowed Radius: {effective_radius:.1f}m)"
268
+ )
269
+
270
+ # ── Face embedding ──────────────────────────────────────────────────
271
+ if config.isFaceRecognitionEnabled:
272
+ face_embedding = await self.student_repo.get_face_embedding(submission.student_id)
273
+ if not face_embedding:
274
+ raise ValueError("Student face embedding is not registered.")
275
+ else:
276
+ face_embedding = []
277
+
278
+ # ── Duplicate check ─────────────────────────────────────────────────
279
+ existing = await self.attendance_repo.get_by_student_and_session(submission.student_id, submission.session_id)
280
+ if existing:
281
+ raise ValueError("Attendance already submitted for this session.")
282
+
283
+ # ── AI scoring ──────────────────────────────────────────────────────
284
+ if config.isFaceRecognitionEnabled or config.isAiBackgroundValidationEnabled:
285
+ if not submission.image_path:
286
+ raise ValueError("Image is required when verification is enabled.")
287
+ ai_results = await self.ai_orchestrator.analyze_attendance(submission.image_path, face_embedding)
288
+ else:
289
+ ai_results = {"face_score": 1.0, "liveness_score": 1.0, "background_score": 1.0}
290
+
291
+ if not config.isFaceRecognitionEnabled:
292
+ ai_results["face_score"] = 1.0
293
+ ai_results["liveness_score"] = 1.0
294
+
295
+ if not config.isAiBackgroundValidationEnabled:
296
+ ai_results["background_score"] = 1.0
297
+ final_score = (
298
+ settings.FACE_WEIGHT * ai_results["face_score"]
299
+ + settings.LIVENESS_WEIGHT * ai_results["liveness_score"]
300
+ + settings.BACKGROUND_WEIGHT * ai_results["background_score"]
301
+ )
302
+ predicted_status = "Flagged" if geofence_missing else ("Present" if final_score >= settings.PASS_THRESHOLD else "Flagged")
303
+
304
+ # ── Build review token (5-minute TTL) ───────────────────────────────
305
+ review_token = create_access_token(
306
+ subject=submission.student_id,
307
+ role="STUDENT",
308
+ expires_delta=timedelta(minutes=5),
309
+ extra_data={
310
+ "type": "attendance_review",
311
+ "session_id": submission.session_id,
312
+ "face_score": ai_results["face_score"],
313
+ "liveness_score": ai_results["liveness_score"],
314
+ "background_score": ai_results["background_score"],
315
+ "final_ai_score": final_score,
316
+ "predicted_status": predicted_status,
317
+ "geofence_missing": geofence_missing,
318
+ "image_path": submission.image_path,
319
+ "latitude": submission.latitude,
320
+ "longitude": submission.longitude,
321
+ },
322
+ )
323
+
324
+ return {
325
+ "face_score": ai_results["face_score"],
326
+ "liveness_score": ai_results["liveness_score"],
327
+ "background_score": ai_results["background_score"],
328
+ "final_ai_score": final_score,
329
+ "predicted_status": predicted_status,
330
+ "review_token": review_token,
331
+ }
332
+
333
+ async def confirm_attendance(self, student_id: str, review_token: str) -> "Attendance":
334
+ """Confirm a previously analyzed submission using its review token.
335
+
336
+ Decodes the token, validates it, and saves the attendance record
337
+ without re-running AI inference.
338
+ """
339
+ from app.core.security import decode_access_token
340
+
341
+ payload = decode_access_token(review_token)
342
+ if not payload or payload.get("type") != "attendance_review":
343
+ raise ValueError("Invalid or expired review token.")
344
+ if payload.get("sub") != student_id:
345
+ raise ValueError("Review token does not belong to this student.")
346
+
347
+ session_id = payload["session_id"]
348
+
349
+ # Re-check duplicate (student might have confirmed twice)
350
+ existing = await self.attendance_repo.get_by_student_and_session(student_id, session_id)
351
+ if existing:
352
+ raise ValueError("Attendance already submitted for this session.")
353
+
354
+ # Re-check session still active
355
+ session = await self.session_repo.get_by_id(session_id)
356
+ if not session:
357
+ raise ValueError("Attendance session is no longer active.")
358
+
359
+ now = datetime.now(timezone.utc)
360
+ session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime
361
+ if not session.isActive or session_end <= now:
362
+ if session.isActive:
363
+ await self.session_repo.deactivate(session.id)
364
+ try:
365
+ await get_redis().delete(f"session:{session.id}")
366
+ except Exception:
367
+ pass
368
+ raise ValueError("Attendance session is no longer active.")
369
+
370
+ face_score = payload["face_score"]
371
+ liveness_score = payload["liveness_score"]
372
+ background_score = payload["background_score"]
373
+ final_score = payload["final_ai_score"]
374
+ predicted_status = payload["predicted_status"]
375
+ geofence_missing = payload.get("geofence_missing", False)
376
+ image_path = payload["image_path"]
377
+ latitude = payload["latitude"]
378
+ longitude = payload["longitude"]
379
+ remarks = "Missing Geofence Data" if geofence_missing else None
380
+
381
+ attendance_record = await self.attendance_repo.create({
382
+ "studentId": student_id,
383
+ "sessionId": session_id,
384
+ "status": predicted_status,
385
+ "faceScore": face_score,
386
+ "livenessScore": liveness_score,
387
+ "backgroundScore": background_score,
388
+ "finalAiScore": final_score,
389
+ "gpsLatitude": latitude,
390
+ "gpsLongitude": longitude,
391
+ "remarks": remarks,
392
+ })
393
+
394
+ try:
395
+ msg = {"type": "attendance_updated", "session_id": session_id, "status": predicted_status}
396
+ await manager.send_personal_message(msg, student_id=student_id)
397
+ msg["student_id"] = student_id
398
+ await manager.broadcast_to_teachers(msg)
399
+ except Exception as e:
400
+ logger.warning("WebSocket broadcast failed: %s", e)
401
+
402
+ if predicted_status == "Flagged":
403
+ try:
404
+ from app.services.notification_service import notify_student_attendance_flagged
405
+ student = await self.student_repo.get_by_id(student_id)
406
+ if student and student.fcmToken:
407
+ ac = await self.class_repo.get_by_id(session.academicClassId)
408
+ class_name = ac.name if ac else "your class"
409
+ await notify_student_attendance_flagged(student.fcmToken, student.firstName or "Student", class_name, attendance_record.id)
410
+ except Exception as e:
411
+ logger.warning("FCM notification failed: %s", e)
412
+
413
+ try:
414
+ from app.services.gamification_service import GamificationService
415
+ await GamificationService().update_streak(student_id, predicted_status)
416
+ except Exception as e:
417
+ logger.warning("Streak update failed: %s", e)
418
+
419
+ if predicted_status == "Present" and image_path and os.path.exists(image_path):
420
+ try:
421
+ os.remove(image_path)
422
+ except Exception as e:
423
+ logger.error("Failed to remove temp image %s: %s", image_path, e, exc_info=True)
424
+
425
+ return attendance_record
426
+
427
+ async def register_face(self, student_id: str, image_path: str) -> bool:
428
+ embedding = await self.ai_orchestrator.extract_face_embedding(image_path)
429
+ if not embedding:
430
+ return False
431
+ await self.student_repo.update_face_embedding(student_id, embedding)
432
+ return True
433
+
434
+ async def review_attendance(self, attendance_id: str, status: str, remarks: str) -> bool:
435
+ record = await self.attendance_repo.get_by_id(attendance_id)
436
+ if not record or record.status != "Flagged":
437
+ return False
438
+
439
+ await self.attendance_repo.update_review(attendance_id, status, remarks)
440
+
441
+ try:
442
+ msg = {"type": "attendance_updated", "session_id": record.sessionId, "status": status}
443
+ await manager.send_personal_message(msg, student_id=record.studentId)
444
+ msg["student_id"] = record.studentId
445
+ await manager.broadcast_to_teachers(msg)
446
+ except Exception as e:
447
+ logger.warning("WebSocket broadcast failed: %s", e)
448
+
449
+ try:
450
+ from app.services.notification_service import notify_student_attendance_reviewed
451
+ student = await self.student_repo.get_by_id(record.studentId)
452
+ if student and student.fcmToken:
453
+ ac = await self.class_repo.get_by_id(record.session.academicClassId if record.session else "")
454
+ class_name = ac.name if ac else "your class"
455
+ await notify_student_attendance_reviewed(student.fcmToken, status, class_name)
456
+ except Exception as e:
457
+ logger.warning("FCM notification failed: %s", e)
458
+
459
+ try:
460
+ from app.services.gamification_service import GamificationService
461
+ await GamificationService().recalculate_student_streak(record.studentId)
462
+ except Exception as e:
463
+ logger.warning("Failed to recalculate streak for student %s: %s", record.studentId, e)
464
+
465
+ return True
backend/app/services/auth_service.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from fastapi import HTTPException, status
4
+
5
+ from app.core.security import hash_password, verify_password, create_access_token
6
+ from app.db.client import db
7
+ from app.repositories.user_repo import UserRepository
8
+ from app.repositories.student_repo import StudentRepository
9
+ from app.repositories.teacher_repo import TeacherRepository
10
+ from app.schemas.auth import Token, UserLogin
11
+ from app.schemas.student import StudentCreate, StudentResponse
12
+ from app.schemas.teacher import TeacherCreate, TeacherResponse
13
+
14
+
15
+ class AuthService:
16
+ def __init__(self) -> None:
17
+ self.user_repo = UserRepository()
18
+ self.student_repo = StudentRepository()
19
+ self.teacher_repo = TeacherRepository()
20
+
21
+ async def authenticate(self, login_data: UserLogin) -> Optional[Token]:
22
+ user = await self.user_repo.get_by_email(login_data.email)
23
+ if not user or not verify_password(login_data.password, user.hashedPassword):
24
+ return None
25
+
26
+ if user.role == "STUDENT":
27
+ student = await self.student_repo.get_by_user_id(user.id)
28
+ if student and login_data.device_uuid:
29
+ if not student.deviceUuid:
30
+ await db.student.update(
31
+ where={"id": student.id}, data={"deviceUuid": login_data.device_uuid}
32
+ )
33
+ elif student.deviceUuid != login_data.device_uuid:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_403_FORBIDDEN,
36
+ detail="Account is bound to another device.",
37
+ )
38
+
39
+ token = create_access_token(subject=user.id, role=user.role)
40
+ return Token(access_token=token, token_type="bearer", role=user.role)
41
+
42
+ async def register_student(self, data: StudentCreate) -> Optional[StudentResponse]:
43
+ if await self.user_repo.get_by_email(data.email):
44
+ return None
45
+ hashed = hash_password(data.password)
46
+ user = await self.user_repo.create(email=data.email, password_hash=hashed, role="STUDENT")
47
+ student = await self.student_repo.create(user_id=user.id, enrollment=data.enrollment_number)
48
+ return StudentResponse(id=student.id, user_id=user.id, enrollment_number=student.enrollmentNumber, email=user.email)
49
+
50
+ async def register_teacher(self, data: TeacherCreate) -> Optional[TeacherResponse]:
51
+ if await self.user_repo.get_by_email(data.email):
52
+ return None
53
+ hashed = hash_password(data.password)
54
+ user = await self.user_repo.create(email=data.email, password_hash=hashed, role="TEACHER")
55
+ teacher = await self.teacher_repo.create(user_id=user.id, department=data.department_id, designation=data.designation_id)
56
+ return TeacherResponse(id=teacher.id, user_id=user.id, department=teacher.department, designation=teacher.designation, email=user.email)
backend/app/services/device_change_service.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from fastapi import HTTPException, status
3
+ from app.db.client import db
4
+ from app.core.security import verify_password
5
+ from app.repositories.user_repo import UserRepository
6
+ from app.repositories.student_repo import StudentRepository
7
+ from app.schemas.auth import DeviceChangeRequestCreate
8
+ from app.schemas.teacher import DeviceChangeResponse
9
+
10
+ class DeviceChangeService:
11
+ def __init__(self) -> None:
12
+ self.user_repo = UserRepository()
13
+ self.student_repo = StudentRepository()
14
+
15
+ async def request_device_change(self, data: DeviceChangeRequestCreate) -> None:
16
+ user = await self.user_repo.get_by_email(data.email)
17
+ if not user or not verify_password(data.password, user.hashedPassword) or user.role != "STUDENT":
18
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
19
+
20
+ student = await self.student_repo.get_by_user_id(user.id)
21
+ if not student:
22
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student record not found")
23
+
24
+ # Check if already has pending request
25
+ pending_request = await db.devicechangerequest.find_first(
26
+ where={
27
+ "studentId": student.id,
28
+ "status": "PENDING"
29
+ }
30
+ )
31
+
32
+ if pending_request:
33
+ # Update existing pending request
34
+ await db.devicechangerequest.update(
35
+ where={"id": pending_request.id},
36
+ data={
37
+ "newDeviceUuid": data.new_device_uuid,
38
+ "reason": data.reason
39
+ }
40
+ )
41
+ else:
42
+ # Create new request
43
+ await db.devicechangerequest.create(
44
+ data={
45
+ "studentId": student.id,
46
+ "newDeviceUuid": data.new_device_uuid,
47
+ "reason": data.reason,
48
+ "status": "PENDING"
49
+ }
50
+ )
51
+
52
+ async def get_pending_requests(self, teacher_id: str) -> List[DeviceChangeResponse]:
53
+ # For simplicity, returning all pending requests in the system.
54
+ # Ideally, we would filter by department or class.
55
+ records = await db.devicechangerequest.find_many(
56
+ where={"status": "PENDING"},
57
+ include={
58
+ "student": {
59
+ "include": {
60
+ "user": True
61
+ }
62
+ }
63
+ },
64
+ order={"createdAt": "desc"}
65
+ )
66
+
67
+ response_list = []
68
+ for r in records:
69
+ name = f"{r.student.firstName or ''} {r.student.lastName or ''}".strip()
70
+ response_list.append(DeviceChangeResponse(
71
+ id=r.id,
72
+ student_id=r.studentId,
73
+ student_name=name or "Unknown",
74
+ enrollment_number=r.student.enrollmentNumber,
75
+ new_device_uuid=r.newDeviceUuid,
76
+ reason=r.reason,
77
+ status=r.status,
78
+ approved_by=r.approvedBy,
79
+ created_at=r.createdAt,
80
+ updated_at=r.updatedAt
81
+ ))
82
+ return response_list
83
+
84
+ async def approve_request(self, request_id: str, teacher_id: str, new_status: str) -> bool:
85
+ request_record = await db.devicechangerequest.find_unique(where={"id": request_id})
86
+ if not request_record or request_record.status != "PENDING":
87
+ return False
88
+
89
+ if new_status == "APPROVED":
90
+ # Update student device_uuid
91
+ await db.student.update(
92
+ where={"id": request_record.studentId},
93
+ data={"deviceUuid": request_record.newDeviceUuid}
94
+ )
95
+
96
+ # Update request status
97
+ await db.devicechangerequest.update(
98
+ where={"id": request_id},
99
+ data={
100
+ "status": new_status,
101
+ "approvedBy": teacher_id
102
+ }
103
+ )
104
+
105
+ return True
backend/app/services/gamification_service.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Tuple
3
+
4
+ from app.core.logging_config import get_logger
5
+ from app.repositories.student_repo import StudentRepository
6
+ from app.repositories.attendance_repo import AttendanceRepository
7
+
8
+ logger = get_logger("app.gamification")
9
+
10
+
11
+ class GamificationService:
12
+ def __init__(self):
13
+ self.student_repo = StudentRepository()
14
+ self.attendance_repo = AttendanceRepository()
15
+
16
+ async def update_streak(self, student_id: str, attendance_status: str) -> dict:
17
+ """Helper to compatibility-wrap recalculate_student_streak."""
18
+ return await self.recalculate_student_streak(student_id)
19
+
20
+ async def recalculate_student_streak(self, student_id: str) -> dict:
21
+ """Recalculate student streak based on historical attendance logs and active sessions."""
22
+ student = await self.student_repo.get_by_id(student_id)
23
+ if not student:
24
+ return {"error": "Student not found"}
25
+
26
+ from app.db.client import db
27
+ enrollments = await db.enrollment.find_many(where={"studentId": student_id})
28
+ class_ids = [e.academicClassId for e in enrollments]
29
+
30
+ if not class_ids:
31
+ await self.student_repo.update_streak(student_id, 0, student.highestStreak or 0)
32
+ await self._update_redis_score(student_id, 0, student.highestStreak or 0)
33
+ return {"current_streak": 0, "highest_streak": student.highestStreak or 0}
34
+
35
+ now = datetime.now(timezone.utc)
36
+
37
+ # Get all sessions that have already started
38
+ sessions = await db.session.find_many(
39
+ where={
40
+ "academicClassId": {"in": class_ids},
41
+ "startTime": {"lte": now}
42
+ },
43
+ order={"startTime": "desc"}
44
+ )
45
+
46
+ attendance = await db.attendance.find_many(where={"studentId": student_id})
47
+ attendance_map = {a.sessionId: a for a in attendance}
48
+
49
+ leaves = await db.leaverequest.find_many(where={"studentId": student_id, "status": "APPROVED"})
50
+
51
+ def is_on_leave(session_start: datetime) -> bool:
52
+ s_start = session_start.replace(tzinfo=timezone.utc) if session_start.tzinfo is None else session_start
53
+ for leave in leaves:
54
+ l_start = leave.startDate.replace(tzinfo=timezone.utc) if leave.startDate.tzinfo is None else leave.startDate
55
+ l_end = leave.endDate.replace(tzinfo=timezone.utc) if leave.endDate.tzinfo is None else leave.endDate
56
+ if l_start <= s_start <= l_end:
57
+ return True
58
+ return False
59
+
60
+ # Filter out active sessions where student hasn't checked in yet
61
+ valid_sessions = []
62
+ for s in sessions:
63
+ s_end = s.endTime.replace(tzinfo=timezone.utc) if s.endTime.tzinfo is None else s.endTime
64
+ is_active = s.isActive and s_end > now
65
+ has_checked_in = s.id in attendance_map and attendance_map[s.id].status in ("Present", "Approved")
66
+
67
+ if is_active and not has_checked_in:
68
+ continue
69
+ valid_sessions.append(s)
70
+
71
+ # 1. Current streak calculation (descending order)
72
+ current_streak = 0
73
+ for s in valid_sessions:
74
+ att = attendance_map.get(s.id)
75
+ status = att.status if att else None
76
+
77
+ if status in ("Present", "Approved"):
78
+ current_streak += 1
79
+ elif is_on_leave(s.startTime):
80
+ continue
81
+ elif status == "Flagged":
82
+ continue
83
+ else:
84
+ break
85
+
86
+ # 2. Highest streak calculation (ascending order)
87
+ highest_streak = student.highestStreak or 0
88
+ running_streak = 0
89
+ for s in reversed(valid_sessions):
90
+ att = attendance_map.get(s.id)
91
+ status = att.status if att else None
92
+
93
+ if status in ("Present", "Approved"):
94
+ running_streak += 1
95
+ highest_streak = max(highest_streak, running_streak)
96
+ elif is_on_leave(s.startTime):
97
+ continue
98
+ elif status == "Flagged":
99
+ continue
100
+ else:
101
+ running_streak = 0
102
+
103
+ highest_streak = max(highest_streak, current_streak)
104
+
105
+ await self.student_repo.update_streak(student_id, current_streak, highest_streak)
106
+ await self._update_redis_score(student_id, current_streak, highest_streak)
107
+ return {"current_streak": current_streak, "highest_streak": highest_streak}
108
+
109
+ async def calculate_consecutive_absences(self, student_id: str, days: int = 3) -> int:
110
+ end = datetime.now(timezone.utc)
111
+ records = await self.attendance_repo.get_by_student_in_date_range(
112
+ student_id=student_id, start_date=end - timedelta(days=7), end_date=end
113
+ )
114
+ consecutive = 0
115
+ for record in sorted(records, key=lambda r: r.createdAt, reverse=True):
116
+ if record.status == "Absent":
117
+ consecutive += 1
118
+ else:
119
+ break
120
+ return consecutive
121
+
122
+ async def get_student_stats(self, student_id: str) -> dict:
123
+ student = await self.student_repo.get_by_id(student_id)
124
+ if not student:
125
+ return {}
126
+
127
+ all_records = await self.attendance_repo.get_by_student_id(student_id)
128
+ total = len(all_records)
129
+ present = sum(1 for r in all_records if r.status in ("Present", "Approved"))
130
+ absent = sum(1 for r in all_records if r.status == "Absent")
131
+ flagged = sum(1 for r in all_records if r.status == "Flagged")
132
+ excused = sum(1 for r in all_records if r.status == "Excused")
133
+
134
+ return {
135
+ "current_streak": student.currentStreak or 0,
136
+ "highest_streak": student.highestStreak or 0,
137
+ "total_classes": total,
138
+ "present_count": present,
139
+ "absent_count": absent,
140
+ "flagged_count": flagged,
141
+ "excused_count": excused,
142
+ "attendance_percentage": round((present / total * 100) if total > 0 else 0, 2),
143
+ }
144
+
145
+ def _get_redis_client(self):
146
+ """Return the active Redis client if available."""
147
+ try:
148
+ from app.db.redis import get_redis
149
+ return get_redis()
150
+ except Exception as e:
151
+ logger.warning("Redis client not available: %s", e)
152
+ return None
153
+
154
+ async def _update_redis_score(self, student_id: str, current_streak: int, highest_streak: int) -> None:
155
+ """Update a student's score in the Redis leaderboard."""
156
+ try:
157
+ redis = self._get_redis_client()
158
+ if redis:
159
+ all_records = await self.attendance_repo.get_by_student_id(student_id)
160
+ present_count = sum(1 for r in all_records if r.status in ("Present", "Approved"))
161
+ new_points = present_count * 50 + highest_streak * 100 + current_streak * 20
162
+ await redis.zadd("leaderboard:points", {student_id: float(new_points)})
163
+ except Exception as e:
164
+ logger.warning("Failed to update leaderboard cache: %s", e)
165
+
166
+ async def get_leaderboard(self, current_student_id: str) -> dict:
167
+ """Fetch the leaderboard from Redis, falling back to DB if empty."""
168
+ redis = self._get_redis_client()
169
+ cache_key = "leaderboard:points"
170
+
171
+ try:
172
+ if redis and not await redis.exists(cache_key):
173
+ await self._rebuild_leaderboard_cache(redis, cache_key)
174
+ except Exception as e:
175
+ logger.warning("Redis operation failed in leaderboard check: %s", e)
176
+
177
+ leaderboard_data = []
178
+ if redis:
179
+ leaderboard_data = await self._fetch_leaderboard_from_cache(redis, cache_key)
180
+
181
+ if not leaderboard_data:
182
+ return await self._get_leaderboard_from_db(current_student_id)
183
+
184
+ user_rank, user_points = await self._fetch_user_rank_and_points(redis, cache_key, current_student_id)
185
+ return {
186
+ "leaderboard": leaderboard_data,
187
+ "user_rank": user_rank,
188
+ "user_points": user_points
189
+ }
190
+
191
+ async def _rebuild_leaderboard_cache(self, redis, cache_key: str) -> None:
192
+ """Rebuild the leaderboard cache from DB data."""
193
+ if not redis:
194
+ return
195
+ try:
196
+ from app.db.client import db
197
+ students = await db.student.find_many(
198
+ where={"user": {"is": {"isActive": True}}},
199
+ include={"attendance": True}
200
+ )
201
+ scores_dict = {}
202
+ for s in students:
203
+ present_count = sum(1 for r in s.attendance if r.status in ("Present", "Approved"))
204
+ points = present_count * 50 + (s.highestStreak or 0) * 100 + (s.currentStreak or 0) * 20
205
+ scores_dict[s.id] = float(points)
206
+ if scores_dict:
207
+ await redis.zadd(cache_key, scores_dict)
208
+ await redis.expire(cache_key, 3600)
209
+ except Exception as e:
210
+ logger.error("Failed to rebuild leaderboard cache: %s", e, exc_info=True)
211
+
212
+ async def _fetch_leaderboard_from_cache(self, redis, cache_key: str) -> list:
213
+ """Fetch top 10 students from Redis cache and load details from DB."""
214
+ try:
215
+ top_members = await redis.zrevrange(cache_key, 0, 9, withscores=True)
216
+ if not top_members:
217
+ return []
218
+ top_ids = [m[0] for m in top_members]
219
+ from app.db.client import db
220
+ top_students = await db.student.find_many(where={"id": {"in": top_ids}})
221
+ students_map = {s.id: s for s in top_students}
222
+
223
+ leaderboard_data = []
224
+ for s_id, score in top_members:
225
+ s = students_map.get(s_id)
226
+ if s:
227
+ name = f"{s.firstName or ''} {s.lastName or ''}".strip() or "Student"
228
+ leaderboard_data.append({
229
+ "student_id": s_id,
230
+ "name": name,
231
+ "points": int(score),
232
+ "current_streak": s.currentStreak or 0,
233
+ })
234
+ return leaderboard_data
235
+ except Exception as e:
236
+ logger.error("Failed to fetch leaderboard from cache: %s", e, exc_info=True)
237
+ return []
238
+
239
+ async def _fetch_user_rank_and_points(self, redis, cache_key: str, student_id: str) -> Tuple[int | None, int]:
240
+ """Fetch rank and points for a specific user from Redis."""
241
+ if not redis:
242
+ return None, 0
243
+ try:
244
+ current_rank_0 = await redis.zrevrank(cache_key, student_id)
245
+ user_rank = current_rank_0 + 1 if current_rank_0 is not None else None
246
+ current_score = await redis.zscore(cache_key, student_id)
247
+ user_points = int(current_score) if current_score is not None else 0
248
+ return user_rank, user_points
249
+ except Exception as e:
250
+ logger.warning("Failed to fetch user rank from Redis: %s", e)
251
+ return None, 0
252
+
253
+ async def _get_leaderboard_from_db(self, current_student_id: str) -> dict:
254
+ """Generate leaderboard directly from database query (fallback)."""
255
+ try:
256
+ from app.db.client import db
257
+ students = await db.student.find_many(
258
+ where={"user": {"is": {"isActive": True}}},
259
+ include={"attendance": True}
260
+ )
261
+ student_list = []
262
+ for s in students:
263
+ present_count = sum(1 for r in s.attendance if r.status in ("Present", "Approved"))
264
+ points = present_count * 50 + (s.highestStreak or 0) * 100 + (s.currentStreak or 0) * 20
265
+ student_list.append((s, points))
266
+
267
+ student_list.sort(key=lambda x: x[1], reverse=True)
268
+
269
+ leaderboard_data = []
270
+ for s, points in student_list[:10]:
271
+ name = f"{s.firstName or ''} {s.lastName or ''}".strip() or "Student"
272
+ leaderboard_data.append({
273
+ "student_id": s.id,
274
+ "name": name,
275
+ "points": points,
276
+ "current_streak": s.currentStreak or 0,
277
+ })
278
+
279
+ user_rank = None
280
+ user_points = 0
281
+ for index, (s, points) in enumerate(student_list):
282
+ if s.id == current_student_id:
283
+ user_rank = index + 1
284
+ user_points = points
285
+ break
286
+ return {
287
+ "leaderboard": leaderboard_data,
288
+ "user_rank": user_rank,
289
+ "user_points": user_points
290
+ }
291
+ except Exception as e:
292
+ logger.error("Database fallback leaderboard query failed: %s", e, exc_info=True)
293
+ return {"leaderboard": [], "user_rank": None, "user_points": 0}
backend/app/services/leave_service.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from prisma.models import LeaveRequest
4
+
5
+ from app.core.logging_config import get_logger
6
+ from app.repositories.leave_repo import LeaveRepository
7
+ from app.repositories.attendance_repo import AttendanceRepository
8
+ from app.repositories.enrollment_repo import EnrollmentRepository
9
+ from app.repositories.session_repo import SessionRepository
10
+
11
+ logger = get_logger("app.leave_service")
12
+
13
+
14
+ class LeaveService:
15
+ def __init__(self):
16
+ self.leave_repo = LeaveRepository()
17
+ self.attendance_repo = AttendanceRepository()
18
+ self.enrollment_repo = EnrollmentRepository()
19
+ self.session_repo = SessionRepository()
20
+
21
+ async def approve_leave(self, leave_id: str, teacher_id: str, status: str, approver_note: Optional[str] = None) -> Optional[LeaveRequest]:
22
+ leave = await self.leave_repo.get_by_id(leave_id)
23
+ if not leave:
24
+ return None
25
+
26
+ updated_leave = await self.leave_repo.update_status(
27
+ leave_id=leave_id, status=status, approved_by=teacher_id, approver_note=approver_note
28
+ )
29
+
30
+ if status == "APPROVED":
31
+ await self._mark_excused_attendance(leave)
32
+ logger.info("Leave approved: student=%s dates=%s to %s", leave.studentId, leave.startDate, leave.endDate)
33
+
34
+ try:
35
+ from app.services.notification_service import notify_student_leave_status
36
+ if leave.student and leave.student.fcmToken:
37
+ await notify_student_leave_status(leave.student.fcmToken, status)
38
+ except Exception as e:
39
+ logger.warning("FCM notification failed: %s", e)
40
+
41
+ return updated_leave
42
+
43
+ async def _mark_excused_attendance(self, leave: LeaveRequest):
44
+ sessions = await self.session_repo.get_sessions_in_date_range(
45
+ start_date=leave.startDate, end_date=leave.endDate
46
+ )
47
+ enrollments = await self.enrollment_repo.get_by_student_id(leave.studentId)
48
+ enrolled_class_ids = {e.academicClassId for e in enrollments}
49
+ relevant_sessions = [s for s in sessions if s.academicClassId in enrolled_class_ids]
50
+
51
+ for session in relevant_sessions:
52
+ existing = await self.attendance_repo.get_by_student_and_session(
53
+ student_id=leave.studentId, session_id=session.id
54
+ )
55
+ data = {"status": "Excused", "remarks": f"Approved leave: {leave.reason}"}
56
+ if existing:
57
+ await self.attendance_repo.update(attendance_id=existing.id, data=data)
58
+ else:
59
+ await self.attendance_repo.create({
60
+ "studentId": leave.studentId, "sessionId": session.id,
61
+ "faceScore": 0.0, "livenessScore": 0.0, "backgroundScore": 0.0,
62
+ "finalAiScore": 0.0, "gpsLatitude": 0.0, "gpsLongitude": 0.0,
63
+ **data,
64
+ })
65
+
backend/app/services/notification_service.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from app.core.logging_config import get_logger
5
+
6
+ logger = get_logger("app.notification")
7
+
8
+ try:
9
+ import firebase_admin
10
+ from firebase_admin import credentials, messaging
11
+
12
+ _cred_path = "firebase-credentials.json"
13
+ try:
14
+ if not firebase_admin._apps:
15
+ cred = credentials.Certificate(_cred_path)
16
+ firebase_admin.initialize_app(cred)
17
+ _fcm_available = True
18
+ logger.info("Firebase Admin SDK initialized successfully")
19
+ except Exception as e:
20
+ _fcm_available = False
21
+ logger.warning("Firebase Admin SDK not available: %s. Push notifications disabled.", e)
22
+
23
+ except ImportError:
24
+ _fcm_available = False
25
+ logger.info("firebase-admin not installed. Push notifications disabled.")
26
+
27
+
28
+ async def send_push_notification(token: str, title: str, body: str, data: Optional[dict] = None) -> bool:
29
+ if not _fcm_available or not token:
30
+ return False
31
+
32
+ try:
33
+ message = messaging.Message(
34
+ notification=messaging.Notification(title=title, body=body),
35
+ data={k: str(v) for k, v in (data or {}).items()},
36
+ token=token,
37
+ )
38
+ response = await asyncio.to_thread(messaging.send, message)
39
+ logger.info("FCM sent: %s", response)
40
+ return True
41
+ except Exception as e:
42
+ logger.warning("FCM send failed: %s", e)
43
+ return False
44
+
45
+
46
+ async def notify_student_attendance_flagged(student_fcm_token: str, student_name: str, class_name: str, attendance_id: str):
47
+ await send_push_notification(
48
+ token=student_fcm_token,
49
+ title="Attendance Flagged",
50
+ body=f"Hi {student_name}, your attendance for {class_name} has been flagged and requires review.",
51
+ data={"route": "/flagged_detail", "attendance_id": attendance_id},
52
+ )
53
+
54
+
55
+ async def notify_student_attendance_reviewed(student_fcm_token: str, status: str, class_name: str):
56
+ await send_push_notification(
57
+ token=student_fcm_token,
58
+ title=f"Attendance {status}",
59
+ body=f"Your attendance for {class_name} has been reviewed and marked as {status}.",
60
+ data={"route": "/history"},
61
+ )
62
+
63
+
64
+ async def notify_student_leave_status(student_fcm_token: str, status: str):
65
+ await send_push_notification(
66
+ token=student_fcm_token,
67
+ title=f"Leave {status}",
68
+ body=f"Your leave request has been {status.lower()}.",
69
+ data={"route": "/leave/history"},
70
+ )
backend/app/services/session_service.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Optional
3
+
4
+ from app.core.logging_config import get_logger
5
+ from app.db.redis import get_redis
6
+ from app.repositories.session_repo import SessionRepository
7
+ from app.repositories.class_repo import ClassRepository
8
+ from app.schemas.teacher import SessionResponse, SessionStart
9
+
10
+ logger = get_logger("app.session")
11
+
12
+
13
+ class SessionService:
14
+ def __init__(self) -> None:
15
+ self.session_repo = SessionRepository()
16
+ self.class_repo = ClassRepository()
17
+
18
+ async def start_session(self, data: SessionStart, teacher_id: str) -> Optional[SessionResponse]:
19
+ subject_class = await self.class_repo.get_by_id(data.academic_class_id)
20
+ if not subject_class or subject_class.teacherId != teacher_id:
21
+ return None
22
+
23
+ active = await self.session_repo.get_active_session_by_class(data.academic_class_id)
24
+ if active:
25
+ await self.close_session(active.id)
26
+
27
+ now = datetime.now(timezone.utc)
28
+ session = await self.session_repo.create(
29
+ class_id=data.academic_class_id, start_time=now, end_time=now + timedelta(minutes=data.duration_minutes)
30
+ )
31
+ return SessionResponse.model_validate(session)
32
+
33
+ async def stop_session(self, session_id: str, teacher_id: str) -> bool:
34
+ session = await self.session_repo.get_by_id(session_id)
35
+ if not session or not session.isActive:
36
+ return False
37
+
38
+ subject_class = await self.class_repo.get_by_id(session.academicClassId)
39
+ if not subject_class or subject_class.teacherId != teacher_id:
40
+ return False
41
+
42
+ await self.close_session(session_id)
43
+ return True
44
+
45
+ async def close_session(self, session_id: str) -> None:
46
+ """Mark session as inactive, delete from Redis, and mark all unsubmitted students as Absent or Excused."""
47
+ session = await self.session_repo.get_by_id(session_id)
48
+ if not session or not session.isActive:
49
+ return
50
+
51
+ # 1. Deactivate in DB
52
+ await self.session_repo.deactivate(session_id)
53
+
54
+ # 2. Delete from Redis
55
+ try:
56
+ await get_redis().delete(f"session:{session_id}")
57
+ except Exception as e:
58
+ logger.warning("Failed to delete session from Redis: %s", e)
59
+
60
+ # 3. Get enrollments and existing attendance
61
+ from app.db.client import db
62
+ from app.services.gamification_service import GamificationService
63
+
64
+ enrollments = await db.enrollment.find_many(
65
+ where={"academicClassId": session.academicClassId},
66
+ include={"student": {"include": {"leaveRequests": True}}}
67
+ )
68
+
69
+ attendance_records = await db.attendance.find_many(where={"sessionId": session_id})
70
+ submitted_student_ids = {a.studentId for a in attendance_records}
71
+
72
+ gamification_service = GamificationService()
73
+ sess_start = session.startTime.replace(tzinfo=timezone.utc) if session.startTime.tzinfo is None else session.startTime
74
+
75
+ for enrollment in enrollments:
76
+ student = enrollment.student
77
+ if not student or student.id in submitted_student_ids:
78
+ continue
79
+
80
+ # Check if student was on approved leave during the session
81
+ on_leave = False
82
+ for leave in student.leaveRequests:
83
+ if leave.status == "APPROVED":
84
+ l_start = leave.startDate.replace(tzinfo=timezone.utc) if leave.startDate.tzinfo is None else leave.startDate
85
+ l_end = leave.endDate.replace(tzinfo=timezone.utc) if leave.endDate.tzinfo is None else leave.endDate
86
+ if l_start <= sess_start <= l_end:
87
+ on_leave = True
88
+ break
89
+
90
+ # Create the record
91
+ status_val = "Excused" if on_leave else "Absent"
92
+ remarks_val = "Excused via approved leave request" if on_leave else "Session ended without student submission"
93
+
94
+ try:
95
+ await db.attendance.create(data={
96
+ "studentId": student.id,
97
+ "sessionId": session_id,
98
+ "status": status_val,
99
+ "faceScore": 0.0,
100
+ "livenessScore": 0.0,
101
+ "backgroundScore": 0.0,
102
+ "finalAiScore": 0.0,
103
+ "gpsLatitude": 0.0,
104
+ "gpsLongitude": 0.0,
105
+ "remarks": remarks_val,
106
+ })
107
+ except Exception as e:
108
+ logger.warning("Failed to create default attendance for student %s: %s", student.id, e)
109
+ continue
110
+
111
+ # Recalculate streak
112
+ try:
113
+ await gamification_service.recalculate_student_streak(student.id)
114
+ except Exception as e:
115
+ logger.warning("Failed to recalculate streak for student %s: %s", student.id, e)