Spaces:
Configuration error
Configuration error
Pratham200Rajbhar commited on
Commit ·
78013c4
0
Parent(s):
first commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/backend-ci.yml +82 -0
- .github/workflows/frontend-ci.yml +46 -0
- .gitignore +35 -0
- README.md +165 -0
- backend/.env.example +23 -0
- backend/.gitignore +66 -0
- backend/.python-version +1 -0
- backend/app/__init__.py +13 -0
- backend/app/api/admin.py +388 -0
- backend/app/api/auth.py +140 -0
- backend/app/api/dependencies.py +108 -0
- backend/app/api/logs.py +20 -0
- backend/app/api/student.py +308 -0
- backend/app/api/teacher.py +247 -0
- backend/app/api/ws.py +177 -0
- backend/app/core/config.py +41 -0
- backend/app/core/logging_config.py +37 -0
- backend/app/core/security.py +46 -0
- backend/app/db/client.py +24 -0
- backend/app/db/redis.py +39 -0
- backend/app/middleware/request_logging.py +25 -0
- backend/app/repositories/attendance_repo.py +64 -0
- backend/app/repositories/class_repo.py +12 -0
- backend/app/repositories/enrollment_repo.py +19 -0
- backend/app/repositories/geofence_repo.py +18 -0
- backend/app/repositories/leave_repo.py +44 -0
- backend/app/repositories/session_repo.py +34 -0
- backend/app/repositories/student_repo.py +47 -0
- backend/app/repositories/system_config_repo.py +40 -0
- backend/app/repositories/teacher_repo.py +10 -0
- backend/app/repositories/user_repo.py +13 -0
- backend/app/schemas/admin.py +97 -0
- backend/app/schemas/attendance.py +56 -0
- backend/app/schemas/auth.py +31 -0
- backend/app/schemas/leave.py +49 -0
- backend/app/schemas/log.py +35 -0
- backend/app/schemas/master_data.py +65 -0
- backend/app/schemas/student.py +81 -0
- backend/app/schemas/system_config.py +16 -0
- backend/app/schemas/teacher.py +176 -0
- backend/app/services/absentee_scanner.py +56 -0
- backend/app/services/admin_service.py +361 -0
- backend/app/services/ai_orchestrator.py +174 -0
- backend/app/services/attendance_service.py +465 -0
- backend/app/services/auth_service.py +56 -0
- backend/app/services/device_change_service.py +105 -0
- backend/app/services/gamification_service.py +293 -0
- backend/app/services/leave_service.py +65 -0
- backend/app/services/notification_service.py +70 -0
- 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)
|