Spaces:
Sleeping
Sleeping
Commit Β·
cd01269
0
Parent(s):
Final synced deploy v2 - Fix session and timeouts
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +17 -0
- .env +4 -0
- .gitattributes +4 -0
- .gitignore +8 -0
- Apis DOCS/Endpoints.md +103 -0
- Apis DOCS/FLUTTER_INTEGRATION_GUIDE.md +162 -0
- Apis DOCS/FULL_API_DOCUMENTATION.md +107 -0
- Dockerfile +30 -0
- Procfile +1 -0
- README.md +63 -0
- __pycache__/config.cpython-311.pyc +0 -0
- app.py +43 -0
- assets/emojis.com cctv-camera-emoji.png +3 -0
- config.py +17 -0
- esp32/gate_controller.ino +175 -0
- models/__init__.py +1 -0
- models/car_plate_best.pt +3 -0
- models/model_ocr.pt +3 -0
- requirements.txt +5 -0
- routes/__init__.py +1 -0
- routes/__pycache__/__init__.cpython-311.pyc +0 -0
- routes/__pycache__/admin_routes.cpython-311.pyc +0 -0
- routes/__pycache__/auth_routes.cpython-311.pyc +0 -0
- routes/__pycache__/camera_routes.cpython-311.pyc +0 -0
- routes/__pycache__/security_routes.cpython-311.pyc +0 -0
- routes/admin_routes.py +160 -0
- routes/auth_routes.py +105 -0
- routes/camera_routes.py +67 -0
- routes/security_routes.py +159 -0
- scratch/test_railway.py +9 -0
- services/__init__.py +1 -0
- services/__pycache__/__init__.cpython-311.pyc +0 -0
- services/__pycache__/api_service.cpython-311.pyc +0 -0
- services/__pycache__/camera_service.cpython-311.pyc +0 -0
- services/__pycache__/ocr_service.cpython-311.pyc +0 -0
- services/api_service.py +415 -0
- services/camera_service.py +73 -0
- services/ocr_service.py +2 -0
- services/supabase_client.py +10 -0
- static/assets/cctv_logo.png +3 -0
- static/css/style.css +418 -0
- templates/admin/dashboard.html +181 -0
- templates/admin/garage.html +189 -0
- templates/admin/history.html +117 -0
- templates/admin/users.html +76 -0
- templates/admin/vehicles.html +175 -0
- templates/auth/login.html +43 -0
- templates/auth/register.html +70 -0
- templates/base.html +16 -0
- templates/partials/sidebar.html +50 -0
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.pyd
|
| 7 |
+
.db
|
| 8 |
+
.env
|
| 9 |
+
.venv
|
| 10 |
+
venv
|
| 11 |
+
.vscode
|
| 12 |
+
.idea
|
| 13 |
+
.qodo
|
| 14 |
+
scratch
|
| 15 |
+
*.log
|
| 16 |
+
Dockerfile
|
| 17 |
+
.dockerignore
|
.env
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FLASK_SECRET_KEY=change-me-in-production-super-secret-key-12345
|
| 2 |
+
BACKEND_API_URL=https://ain-el-aql-backend-production.up.railway.app
|
| 3 |
+
ESP_GATE_URL=http://192.168.1.100/open
|
| 4 |
+
PLATE_COOLDOWN_SECONDS=3
|
.gitattributes
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
static/uploads/*
|
| 5 |
+
!static/uploads/.gitkeep
|
| 6 |
+
venv/
|
| 7 |
+
.vscode/
|
| 8 |
+
.DS_Store
|
Apis DOCS/Endpoints.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ain El Aql - Endpoints Reference
|
| 2 |
+
|
| 3 |
+
This document catalogs all the REST API endpoints available in the backend. Use this as a reference guide when integrating or building frontend clients (Websites, Flutter Apps, dashboards, etc.).
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## π Public & Utility
|
| 8 |
+
| Method | Endpoint | Description |
|
| 9 |
+
|---|---|---|
|
| 10 |
+
| `GET` | `/` | Base API sanity check |
|
| 11 |
+
| `GET` | `/health` | Application health status |
|
| 12 |
+
| `GET` | `/supabase/health` | Supabase connectivity health check |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## π Authentication (`/auth`)
|
| 17 |
+
*Most of these rely on Supabase Auth. They handle user sessions, passwords, and OTP logic.*
|
| 18 |
+
|
| 19 |
+
| Method | Endpoint | Description |
|
| 20 |
+
|---|---|---|
|
| 21 |
+
| `POST` | `/auth/register` | Register a new user |
|
| 22 |
+
| `POST` | `/auth/login` | Login to receive a JWT session token |
|
| 23 |
+
| `POST` | `/auth/forgot-password` | Request a password reset link |
|
| 24 |
+
| `POST` | `/auth/reset-password/resolve-token` | Validate the token from the reset email |
|
| 25 |
+
| `POST` | `/auth/reset-password` | Update password |
|
| 26 |
+
| `GET` | `/auth/reset-password-page` | Web UI for resetting password |
|
| 27 |
+
| `GET` | `/auth/reset-password-bridge` | Mobile deep-link bridge for password reset |
|
| 28 |
+
| `POST` | `/auth/otp/request` | Request an OTP code via Email/Phone |
|
| 29 |
+
| `POST` | `/auth/otp/verify` | Verify the requested OTP code |
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## π€ Profile & Vehicles
|
| 34 |
+
*Requires `Authorization: Bearer <token>`.*
|
| 35 |
+
|
| 36 |
+
| Method | Endpoint | Description |
|
| 37 |
+
|---|---|---|
|
| 38 |
+
| `PUT` | `/profile/update` | Update authenticated user's profile info |
|
| 39 |
+
| `POST` | `/vehicles/add` | Add a new vehicle to user's profile |
|
| 40 |
+
| `GET` | `/vehicles/my` | List authenticated user's vehicles |
|
| 41 |
+
| `PUT` | `/vehicles/{vehicle_id}` | Edit an existing vehicle |
|
| 42 |
+
| `DELETE` | `/vehicles/{vehicle_id}` | Delete/Unlink a vehicle |
|
| 43 |
+
| `POST` | `/vehicles/verify` | Request admin verification for a vehicle |
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## π Notifications
|
| 48 |
+
*Requires `Authorization: Bearer <token>`.*
|
| 49 |
+
|
| 50 |
+
| Method | Endpoint | Description |
|
| 51 |
+
|---|---|---|
|
| 52 |
+
| `POST` | `/notifications/fcm-token` | Register an FCM (Firebase) token for push notifications |
|
| 53 |
+
| `GET` | `/notifications/my` | Get user's personal notification feed |
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## π€ AI Models & Inference Core
|
| 58 |
+
*The core AI pipeline execution paths.*
|
| 59 |
+
|
| 60 |
+
| Method | Endpoint | Description |
|
| 61 |
+
|---|---|---|
|
| 62 |
+
| `GET` | `/models` | List available AI models |
|
| 63 |
+
| `GET` | `/developer/models` | Diagnostic data for loaded models |
|
| 64 |
+
| `POST` | `/predict` | **Main Pipeline:** Upload image, run OCR, update parking session, return gate decision. |
|
| 65 |
+
| `POST` | `/model/infer` | Remote model execution endpoint (Hugging Face integration) |
|
| 66 |
+
| `POST` | `/developer/predict` | Developer debugging for predictions |
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## π
ΏοΈ Parking, Events, and History
|
| 71 |
+
*Requires `Authorization: Bearer <token>`.*
|
| 72 |
+
**Role-aware endpoints:** Regular users only see data for their own vehicles. `admin` and `security` roles see all data globally across the entire garage.
|
| 73 |
+
|
| 74 |
+
| Method | Endpoint | Description |
|
| 75 |
+
|---|---|---|
|
| 76 |
+
| `GET` | `/parking/locations` | Get garage capacities, pricing rules, and occupancy snapshot |
|
| 77 |
+
| `GET` | `/parking/occupancy` | Get pure occupancy statistics |
|
| 78 |
+
| `GET` | `/events/entered-cars` | Feed of cars that entered the garage |
|
| 79 |
+
| `GET` | `/events/leaving-cars-within-5-minutes` | Feed of cars currently in their exit grace period |
|
| 80 |
+
| `GET` | `/events/left-cars` | Feed of cars that have exited the garage completely |
|
| 81 |
+
| `GET` | `/events/inside-cars` | Live feed of cars currently physically inside the garage |
|
| 82 |
+
| `GET` | `/parking/history` | Historical log of all parking sessions |
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## π³ Payments & Gate Control
|
| 87 |
+
|
| 88 |
+
| Method | Endpoint | Description |
|
| 89 |
+
|---|---|---|
|
| 90 |
+
| `POST` | `/payments/paymob/create` | Initialize a Paymob iframe checkout URL |
|
| 91 |
+
| `POST` | `/payments/paymob/webhook` | Webhook listener for Paymob status updates |
|
| 92 |
+
| `POST` | `/gate/decision` | Evaluate if the physical gate should open based on payment status |
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## π‘οΈ Administrative & Security (Strict RBAC)
|
| 97 |
+
*Requires `Authorization: Bearer <token>` with either `admin` or `security` role.*
|
| 98 |
+
|
| 99 |
+
| Method | Endpoint | Description |
|
| 100 |
+
|---|---|---|
|
| 101 |
+
| `GET` | `/events/new-cars` | Real-time polling feed of cars pulling up to cameras (Security Dashboard) |
|
| 102 |
+
| `POST` | `/payments/manual/cash-confirm` | Security action to bypass payment and manually mark session as paid via cash |
|
| 103 |
+
| `POST` | `/admin/maintenance/weekly-refresh` | Internal backend maintenance and cleanup task |
|
Apis DOCS/FLUTTER_INTEGRATION_GUIDE.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flutter Integration Guide
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
Connect Flutter app to Ain El Aql backend to produce a complete parking product:
|
| 6 |
+
|
| 7 |
+
1. camera-based ANPR detection
|
| 8 |
+
2. parking session and payment lifecycle
|
| 9 |
+
3. occupancy and location-aware pricing dashboards
|
| 10 |
+
|
| 11 |
+
## Responsibility Split
|
| 12 |
+
|
| 13 |
+
### Backend + AI team (already implemented)
|
| 14 |
+
|
| 15 |
+
1. AI inference pipeline
|
| 16 |
+
2. Session/event persistence in Supabase
|
| 17 |
+
3. Dynamic pricing and capacity logic
|
| 18 |
+
4. Gate decision logic
|
| 19 |
+
5. Payment endpoints (Paymob + manual cash)
|
| 20 |
+
6. Role-aware history/feed APIs
|
| 21 |
+
|
| 22 |
+
### Flutter team
|
| 23 |
+
|
| 24 |
+
1. Backend auth screens and token management
|
| 25 |
+
2. API client layer and request retries
|
| 26 |
+
3. UI for user/admin/security experiences
|
| 27 |
+
4. Detection screens and result rendering
|
| 28 |
+
5. Payment/gate operation UI flow
|
| 29 |
+
6. Dashboard and reporting screens
|
| 30 |
+
|
| 31 |
+
## Integration Steps
|
| 32 |
+
|
| 33 |
+
1. Configure API base URL in Flutter.
|
| 34 |
+
2. Register/Login through backend auth endpoints and keep access token.
|
| 35 |
+
3. Send token as `Authorization: Bearer ...` to protected endpoints.
|
| 36 |
+
4. Use `POST /predict` for main camera action.
|
| 37 |
+
5. Use `/parking/locations` and `/parking/occupancy` for location cards and dashboard.
|
| 38 |
+
6. Use feeds/history endpoints for timeline and operational views.
|
| 39 |
+
|
| 40 |
+
## Current Cloud Base URL
|
| 41 |
+
|
| 42 |
+
- `https://pant0x-ealpr-ocr.hf.space`
|
| 43 |
+
|
| 44 |
+
Sanity checks:
|
| 45 |
+
|
| 46 |
+
1. `GET /`
|
| 47 |
+
2. `GET /health`
|
| 48 |
+
3. `GET /supabase/health`
|
| 49 |
+
|
| 50 |
+
Current deployment mode is `SUPABASE_ENFORCE_AUTH=false` to unblock integration.
|
| 51 |
+
Before production launch, switch to `true` and ensure token headers are sent on protected calls.
|
| 52 |
+
|
| 53 |
+
## Recommended Flutter Screens
|
| 54 |
+
|
| 55 |
+
1. Auth (login/register/reset)
|
| 56 |
+
2. Home/scan screen
|
| 57 |
+
3. Detection result screen
|
| 58 |
+
4. Inside/entered/left monitoring screens
|
| 59 |
+
5. Parking history screen
|
| 60 |
+
6. Location pricing + occupancy screen
|
| 61 |
+
7. Payment and gate validation screen
|
| 62 |
+
8. Admin/security operations panel
|
| 63 |
+
|
| 64 |
+
## Request/Response Mapping
|
| 65 |
+
|
| 66 |
+
### `POST /auth/register`
|
| 67 |
+
|
| 68 |
+
Flutter sends:
|
| 69 |
+
|
| 70 |
+
1. `first_name` (recommended)
|
| 71 |
+
2. `last_name` (recommended)
|
| 72 |
+
3. `username` (required)
|
| 73 |
+
4. `phone` (required)
|
| 74 |
+
5. `password` (required)
|
| 75 |
+
6. `email` (optional)
|
| 76 |
+
7. `nid` (optional)
|
| 77 |
+
8. optional vehicle payload:
|
| 78 |
+
- `plate_letters_ar`, `plate_numbers_ar`
|
| 79 |
+
- `plate_letters_en`, `plate_numbers_en`
|
| 80 |
+
- `car_model`, `car_color`
|
| 81 |
+
|
| 82 |
+
Flutter reads:
|
| 83 |
+
|
| 84 |
+
1. `user.role` and `user.is_staff`
|
| 85 |
+
2. `session.access_token`
|
| 86 |
+
3. `vehicle` (if provided during registration)
|
| 87 |
+
|
| 88 |
+
### `POST /auth/login`
|
| 89 |
+
|
| 90 |
+
Flutter sends:
|
| 91 |
+
|
| 92 |
+
1. `identifier` (username OR phone OR email OR nid)
|
| 93 |
+
2. `password`
|
| 94 |
+
|
| 95 |
+
Flutter reads:
|
| 96 |
+
|
| 97 |
+
1. `user.role` and `user.is_staff`
|
| 98 |
+
2. `session.access_token`
|
| 99 |
+
3. `session.refresh_token`
|
| 100 |
+
|
| 101 |
+
### `POST /predict`
|
| 102 |
+
|
| 103 |
+
Flutter sends:
|
| 104 |
+
|
| 105 |
+
1. image file
|
| 106 |
+
2. event_type (`entry`/`exit`/`ocr_scan`)
|
| 107 |
+
3. parking_location (named value like `OPERA`)
|
| 108 |
+
|
| 109 |
+
Flutter reads:
|
| 110 |
+
|
| 111 |
+
1. `plate_info` / `plate`
|
| 112 |
+
2. `pricing`
|
| 113 |
+
3. `payment_status`
|
| 114 |
+
4. `gate_decision`
|
| 115 |
+
5. `user_page.split_image_base64`
|
| 116 |
+
6. `admin_page.annotated_image_base64`
|
| 117 |
+
|
| 118 |
+
### Occupancy dashboards
|
| 119 |
+
|
| 120 |
+
Use:
|
| 121 |
+
|
| 122 |
+
1. `GET /parking/locations`
|
| 123 |
+
2. `GET /parking/occupancy`
|
| 124 |
+
|
| 125 |
+
Render:
|
| 126 |
+
|
| 127 |
+
1. `garage.display` (X/total)
|
| 128 |
+
2. each location pricing mode/rates
|
| 129 |
+
3. each location occupancy display and left capacity
|
| 130 |
+
|
| 131 |
+
## Role-based UI Recommendations
|
| 132 |
+
|
| 133 |
+
1. `user`
|
| 134 |
+
- own history/feeds
|
| 135 |
+
- pricing and availability
|
| 136 |
+
2. `admin`
|
| 137 |
+
- all-user operations, analytics, payment controls
|
| 138 |
+
3. `security`
|
| 139 |
+
- gate operations and live occupancy monitoring
|
| 140 |
+
|
| 141 |
+
Role source is backend `user.role` from the `profiles` table.
|
| 142 |
+
|
| 143 |
+
## End-to-End Product Pipeline
|
| 144 |
+
|
| 145 |
+
1. Driver arrives and selects location in app.
|
| 146 |
+
2. Camera scan sends image to `/predict` with `event_type=entry`.
|
| 147 |
+
3. Backend detects plate, creates/updates session, calculates pricing context.
|
| 148 |
+
4. Driver views status and fee preview.
|
| 149 |
+
5. On exit, app sends `/predict` with `event_type=exit`.
|
| 150 |
+
6. Backend validates payment state and returns gate decision.
|
| 151 |
+
7. Staff can use payment endpoints when required.
|
| 152 |
+
8. Dashboards reflect occupancy in near-real-time from open sessions.
|
| 153 |
+
|
| 154 |
+
## Launch Checklist
|
| 155 |
+
|
| 156 |
+
1. Cloud host `/`, `/health`, and `/supabase/health` pass.
|
| 157 |
+
2. Supabase tables/buckets configured and secure.
|
| 158 |
+
3. Auth enforced in production (`SUPABASE_ENFORCE_AUTH=true`).
|
| 159 |
+
4. Pricing/capacity env values loaded in deployment.
|
| 160 |
+
5. Flutter role routing and token flow tested.
|
| 161 |
+
6. Entry/exit/manual payment QA completed.
|
| 162 |
+
7. Security key rotation completed for exposed secrets.
|
Apis DOCS/FULL_API_DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ain El Aql - Full System Architecture & API Documentation
|
| 2 |
+
|
| 3 |
+
This document serves as the ultimate reference for the Ain El Aql backend, covering everything from the database model to all endpoints, and specifically detailing the **Role-Based Access Control (RBAC)** that governs the User, Admin, and Security roles.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 1. Role-Based Access Control (RBAC)
|
| 8 |
+
|
| 9 |
+
The backend uses a single set of APIs whose behavior changes dynamically based on the authenticated user's role.
|
| 10 |
+
|
| 11 |
+
### Roles
|
| 12 |
+
1. **`user`**: Regular customers. They can only see their own registered vehicles, notifications, and parking history.
|
| 13 |
+
2. **`admin` & `security` (Staff)**: Privileged roles that unlock **God View**. When a staff member calls an endpoint like `/events/entered-cars`, they bypass the user filter and see **all cars** in the entire facility. They also gain access to manual override endpoints like `/payments/manual/cash-confirm`.
|
| 14 |
+
3. **`barrier`**: A synthetic machine role intended for the camera/gate systems. They authenticate using a static `BARRIER_API_TOKEN` to run OCR scans and query gate decisions without needing a user account.
|
| 15 |
+
4. **`developer`**: Has access to backend debugging endpoints.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 2. Database Model (Supabase)
|
| 20 |
+
|
| 21 |
+
The relational data is stored in PostgreSQL via Supabase.
|
| 22 |
+
|
| 23 |
+
### Core Tables
|
| 24 |
+
* **`profiles`**: Links to `auth.users.id`. Stores user identity (`first_name`, `email`, `phone_number`, `national_id`), as well as their `role` (e.g., user, admin).
|
| 25 |
+
* **`vehicles`**: Links to `profiles.id` (`owner_id`). Stores the registration data (Arabic/English plates, car make, color, verification status).
|
| 26 |
+
* **`parking_sessions`**: Tracks the lifecycle of a car parking. Links to `vehicles.id`. A session starts on `entry` and closes on `exit`.
|
| 27 |
+
* **`car_events`**: The immutable ledger of everything that happens. Every entry, exit, or OCR scan creates a row here.
|
| 28 |
+
* **`payment_transactions`**: The ledger for payments, linking Paymob callbacks or Manual Cash confirmations to a parking session.
|
| 29 |
+
* **`notification_device_tokens` & `notification_events`**: Handles FCM (Firebase) push notifications.
|
| 30 |
+
|
| 31 |
+
### Storage Buckets
|
| 32 |
+
* `car-raw-images`: Stores the full uncropped images from the CCTV cameras.
|
| 33 |
+
* `car-processed-images`: Stores cropped plates and annotated UI images.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 3. Comprehensive API Endpoints Catalog
|
| 38 |
+
|
| 39 |
+
### π Public & Utility
|
| 40 |
+
* `GET /` - Base API check.
|
| 41 |
+
* `GET /health` - API health check.
|
| 42 |
+
* `GET /supabase/health` - Checks database connectivity.
|
| 43 |
+
|
| 44 |
+
### π Authentication (`/auth`)
|
| 45 |
+
* `POST /auth/register` - Creates a new user in Supabase Auth.
|
| 46 |
+
* `POST /auth/login` - Returns a JWT. If the user is staff, `"is_staff": true` is injected into the response.
|
| 47 |
+
* `POST /auth/otp/request` - Requests an OTP (Email/Phone).
|
| 48 |
+
* `POST /auth/otp/verify` - Verifies an OTP.
|
| 49 |
+
* `POST /auth/forgot-password` - Triggers the Supabase password reset email.
|
| 50 |
+
* `POST /auth/reset-password/resolve-token` - Validates the reset token.
|
| 51 |
+
* `POST /auth/reset-password` - Sets the new password.
|
| 52 |
+
* `GET /auth/reset-password-page` - Web UI fallback for password resets.
|
| 53 |
+
* `GET /auth/reset-password-bridge` - Mobile deep link bridge.
|
| 54 |
+
|
| 55 |
+
### π€ Profile & Vehicles (`/profile`, `/vehicles`)
|
| 56 |
+
* `PUT /profile/update` - Modifies `profiles` table data.
|
| 57 |
+
* `POST /vehicles/add` - Registers a new vehicle to the user.
|
| 58 |
+
* `GET /vehicles/my` - Lists the authenticated user's vehicles.
|
| 59 |
+
* `PUT /vehicles/{vehicle_id}` - Edits a vehicle's details.
|
| 60 |
+
* `DELETE /vehicles/{vehicle_id}` - Unlinks a vehicle.
|
| 61 |
+
* `POST /vehicles/verify` - Submits a vehicle for administrative verification.
|
| 62 |
+
|
| 63 |
+
### π Notifications (`/notifications`)
|
| 64 |
+
* `POST /notifications/fcm-token` - Registers a mobile device token.
|
| 65 |
+
* `GET /notifications/my` - Retrieves the user's unread/read notifications.
|
| 66 |
+
|
| 67 |
+
### π€ AI Models & Inference Core
|
| 68 |
+
* `GET /models` - Lists available YOLO models.
|
| 69 |
+
* `GET /developer/models` - Internal diagnostic for models.
|
| 70 |
+
* `POST /predict` - **The core pipeline endpoint.** Receives an image, runs YOLO car detection -> YOLO plate detection -> OCR. Records the event in Supabase and returns the gate decision and pricing.
|
| 71 |
+
* `POST /model/infer` - Executed by the Hugging Face space when running in remote inference mode.
|
| 72 |
+
* `POST /developer/predict` - Testing endpoint for developers.
|
| 73 |
+
|
| 74 |
+
### π
ΏοΈ Parking, Events, and History
|
| 75 |
+
*(Note: All endpoints below return "Own Data" for Users, and "Global Data" for Admin/Security)*
|
| 76 |
+
* `GET /parking/locations` - Returns garage capacities, pricing rules, and current overall occupancy.
|
| 77 |
+
* `GET /parking/occupancy` - Returns pure occupancy counts.
|
| 78 |
+
* `GET /events/entered-cars` - Feed of cars that have successfully entered.
|
| 79 |
+
* `GET /events/leaving-cars-within-5-minutes` - Feed of cars currently in their exit grace period.
|
| 80 |
+
* `GET /events/left-cars` - Feed of closed sessions.
|
| 81 |
+
* `GET /events/inside-cars` - Live view of all cars physically inside the garage right now.
|
| 82 |
+
* `GET /parking/history` - The permanent ledger of all past parking sessions.
|
| 83 |
+
|
| 84 |
+
### π³ Payments & Gate Control
|
| 85 |
+
* `POST /payments/paymob/create` - Generates a Paymob iframe URL for credit card checkout.
|
| 86 |
+
* `POST /payments/paymob/webhook` - Secure listener for Paymob status updates.
|
| 87 |
+
* `POST /gate/decision` - Calculates if a specific car is allowed to exit right now based on its payment status.
|
| 88 |
+
|
| 89 |
+
### π‘οΈ Exclusive Admin / Security Endpoints
|
| 90 |
+
*(Restricted entirely to the `admin` and `security` roles)*
|
| 91 |
+
* `GET /events/new-cars` - Real-time polling feed of cars directly at the camera right now.
|
| 92 |
+
* `POST /payments/manual/cash-confirm` - Allows a guard to confirm they received cash, bypassing the credit card flow and opening the gate.
|
| 93 |
+
* `POST /admin/maintenance/weekly-refresh` - System cleanup script.
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## 4. End-to-End Flow Example: The `/predict` Pipeline
|
| 98 |
+
|
| 99 |
+
When a camera sends an image to `POST /predict`:
|
| 100 |
+
1. **Auth:** Checked against `BARRIER_API_TOKEN` or a user JWT.
|
| 101 |
+
2. **AI Inference:** The image is passed through 3 AI models (Car -> Plate -> OCR).
|
| 102 |
+
3. **Matching Logic:** The detected plate string is normalized and searched in the `vehicles` table (matching both Arabic and English variants).
|
| 103 |
+
4. **Session Logic:**
|
| 104 |
+
* If `event_type=entry`, a new `parking_sessions` row is created.
|
| 105 |
+
* If `event_type=exit`, the pricing engine calculates the fee.
|
| 106 |
+
5. **Persistence:** Images are uploaded to Supabase Storage, and the `car_events` ledger is updated.
|
| 107 |
+
6. **Response:** Flutter receives a massive JSON containing the plate text, pricing breakdown, and `gate_decision.allowed = true/false`.
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Install system dependencies
|
| 5 |
+
# libglib2.0-0 is often needed for some image processing libraries
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
libglib2.0-0 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Create a non-root user (UID 1000 is required by Hugging Face)
|
| 11 |
+
RUN useradd -m -u 1000 user
|
| 12 |
+
USER user
|
| 13 |
+
ENV HOME=/home/user \
|
| 14 |
+
PATH=/home/user/.local/bin:$PATH
|
| 15 |
+
|
| 16 |
+
WORKDIR $HOME/app
|
| 17 |
+
|
| 18 |
+
# Copy and install dependencies
|
| 19 |
+
COPY --chown=user requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy the application code
|
| 23 |
+
COPY --chown=user . .
|
| 24 |
+
|
| 25 |
+
# Hugging Face Spaces expects the app to listen on port 7860
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
# Run the application with gunicorn
|
| 29 |
+
# Using 1 worker and 8 threads for a balance of performance and resource usage
|
| 30 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "8", "--timeout", "0", "app:app"]
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: gunicorn app:app
|
README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: EALPR FLASK
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Ain El Aql - Security & Admin Webpage
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
This is the Security and Administration dashboard for the **Ain El Aql** Parking Management System. It provides real-time license plate recognition (LPR), garage occupancy tracking, and manual gate control.
|
| 15 |
+
|
| 16 |
+
## π Deployment (Render)
|
| 17 |
+
|
| 18 |
+
This application is designed to be deployed on **Render**.
|
| 19 |
+
|
| 20 |
+
### 1. Requirements
|
| 21 |
+
* A Render account.
|
| 22 |
+
* The code pushed to a GitHub repository.
|
| 23 |
+
|
| 24 |
+
### 2. Environment Variables
|
| 25 |
+
Add the following variables in the Render "Environment" tab:
|
| 26 |
+
* `FLASK_SECRET_KEY`: A secret key for session security.
|
| 27 |
+
* `BACKEND_API_URL`: `https://ain-el-aql-backend-production.up.railway.app`
|
| 28 |
+
* `CAMERA_SOURCE`: The URL of your IP camera stream (e.g., `http://.../video`) or `0` for local testing if running locally.
|
| 29 |
+
|
| 30 |
+
### 3. Build & Start Commands
|
| 31 |
+
* **Build Command**: `pip install -r requirements.txt`
|
| 32 |
+
* **Start Command**: `gunicorn "app:create_app()"`
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## π οΈ Technology Stack
|
| 37 |
+
* **Frontend**: HTML5, CSS3 (Vanilla), JavaScript.
|
| 38 |
+
* **Backend**: Flask (Python).
|
| 39 |
+
* **OCR Engine**: YOLOv8 models hosted on **Hugging Face** via the Railway backend.
|
| 40 |
+
* **Database**: Supabase (via the Railway backend).
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## π Features
|
| 45 |
+
* **Real-time Monitoring**: Live camera feed with periodic remote OCR polling.
|
| 46 |
+
* **Manual Override**: Open the gate manually from the dashboard.
|
| 47 |
+
* **History Ledger**: View complete records of all vehicles that entered or left the facility.
|
| 48 |
+
* **Garage Management**: Track currently inside vehicles and parking occupancy.
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## π Security
|
| 53 |
+
* Role-based access control (Security vs Admin).
|
| 54 |
+
* Encrypted session tokens.
|
| 55 |
+
* CORS configured for cross-origin API calls.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## π Usage
|
| 60 |
+
1. **Login** with your security or admin credentials.
|
| 61 |
+
2. **Start Camera** to begin monitoring the entrance/exit.
|
| 62 |
+
3. **Scan & Process** an image to register a vehicle event (entry/exit).
|
| 63 |
+
4. **Confirm Payments** in the Admin/Garage view for manual cash transactions.
|
__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (1.19 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ain El Aql β Security & Admin Dashboard
|
| 3 |
+
Flask application factory.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import Flask
|
| 7 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 8 |
+
from config import Config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_app():
|
| 12 |
+
app = Flask(__name__)
|
| 13 |
+
app.config.from_object(Config)
|
| 14 |
+
|
| 15 |
+
# Trust proxy headers (required for Hugging Face)
|
| 16 |
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
| 17 |
+
|
| 18 |
+
# Required for Hugging Face Spaces iframes
|
| 19 |
+
app.config.update(
|
| 20 |
+
SESSION_COOKIE_SAMESITE='None',
|
| 21 |
+
SESSION_COOKIE_SECURE=True,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# ββ Register blueprints ββββββββββββββββββββββββββββββββββ
|
| 25 |
+
from routes.auth_routes import auth_bp
|
| 26 |
+
from routes.admin_routes import admin_bp
|
| 27 |
+
from routes.security_routes import security_bp
|
| 28 |
+
from routes.camera_routes import camera_bp
|
| 29 |
+
|
| 30 |
+
app.register_blueprint(auth_bp)
|
| 31 |
+
app.register_blueprint(admin_bp)
|
| 32 |
+
app.register_blueprint(security_bp)
|
| 33 |
+
app.register_blueprint(camera_bp)
|
| 34 |
+
|
| 35 |
+
return app
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app = create_app()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
application = create_app()
|
| 43 |
+
application.run(debug=True, host="0.0.0.0", port=5000)
|
assets/emojis.com cctv-camera-emoji.png
ADDED
|
Git LFS Details
|
config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv(override=True)
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Config:
|
| 8 |
+
SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "fallback-dev-key")
|
| 9 |
+
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "https://ain-el-aql-backend-production.up.railway.app")
|
| 10 |
+
ESP_GATE_URL = os.getenv("ESP_GATE_URL", "http://192.168.1.100/open")
|
| 11 |
+
PLATE_COOLDOWN_SECONDS = int(os.getenv("PLATE_COOLDOWN_SECONDS", "3"))
|
| 12 |
+
CAMERA_SOURCE = os.getenv("CAMERA_SOURCE", "0")
|
| 13 |
+
# If CAMERA_SOURCE is numeric string, convert to int
|
| 14 |
+
try:
|
| 15 |
+
CAMERA_SOURCE = int(CAMERA_SOURCE)
|
| 16 |
+
except ValueError:
|
| 17 |
+
pass
|
esp32/gate_controller.ino
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* Ain El Aql β ESP32 Gate Controller
|
| 3 |
+
*
|
| 4 |
+
* Two operation modes:
|
| 5 |
+
* 1. WiFi Mode: Polls the cloud backend every 1 second for gate commands.
|
| 6 |
+
* Works from anywhere in the world (real hardware + Wokwi simulation).
|
| 7 |
+
* 2. USB Serial Mode: Receives '1' from the browser's Web Serial API.
|
| 8 |
+
* Works when ESP32 is plugged into the security PC via USB.
|
| 9 |
+
*
|
| 10 |
+
* Both modes work simultaneously β whichever signal arrives first opens the gate.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
#include <ESP32Servo.h>
|
| 14 |
+
#include <WiFi.h>
|
| 15 |
+
#include <HTTPClient.h>
|
| 16 |
+
#include <ArduinoJson.h>
|
| 17 |
+
|
| 18 |
+
// ββ Configuration βββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
// Change these to match your setup:
|
| 20 |
+
const char* WIFI_SSID = "YOUR_WIFI_SSID";
|
| 21 |
+
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
|
| 22 |
+
|
| 23 |
+
// Your backend URL (Railway deployment)
|
| 24 |
+
const char* BACKEND_URL = "https://ain-el-aql-backend-production.up.railway.app";
|
| 25 |
+
const char* GATE_LOCATION = "OPERA";
|
| 26 |
+
|
| 27 |
+
// ββ Hardware ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
Servo gateServo;
|
| 29 |
+
const int servoPin = 18; // GPIO 18 on ESP32
|
| 30 |
+
const int LED_PIN = 2; // Built-in LED for status
|
| 31 |
+
|
| 32 |
+
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
bool gateIsOpen = false;
|
| 34 |
+
unsigned long lastPollTime = 0;
|
| 35 |
+
const unsigned long POLL_INTERVAL_MS = 1000; // Poll every 1 second
|
| 36 |
+
|
| 37 |
+
void setup() {
|
| 38 |
+
Serial.begin(115200);
|
| 39 |
+
Serial.println("\nββββββββββββββββββββββββββββββββββββββββ");
|
| 40 |
+
Serial.println("β Ain El Aql β Gate Controller v2 β");
|
| 41 |
+
Serial.println("ββββββββββββββββββββββββββββββββββββββββ");
|
| 42 |
+
|
| 43 |
+
// Setup servo
|
| 44 |
+
ESP32PWM::allocateTimer(0);
|
| 45 |
+
gateServo.setPeriodHertz(50);
|
| 46 |
+
gateServo.attach(servoPin, 500, 2400);
|
| 47 |
+
gateServo.write(0); // Start closed
|
| 48 |
+
|
| 49 |
+
// Setup LED
|
| 50 |
+
pinMode(LED_PIN, OUTPUT);
|
| 51 |
+
digitalWrite(LED_PIN, LOW);
|
| 52 |
+
|
| 53 |
+
// Connect to WiFi
|
| 54 |
+
Serial.print("[WiFi] Connecting to ");
|
| 55 |
+
Serial.print(WIFI_SSID);
|
| 56 |
+
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
| 57 |
+
|
| 58 |
+
int attempts = 0;
|
| 59 |
+
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
| 60 |
+
delay(500);
|
| 61 |
+
Serial.print(".");
|
| 62 |
+
attempts++;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (WiFi.status() == WL_CONNECTED) {
|
| 66 |
+
Serial.println(" β
Connected!");
|
| 67 |
+
Serial.print("[WiFi] IP: ");
|
| 68 |
+
Serial.println(WiFi.localIP());
|
| 69 |
+
digitalWrite(LED_PIN, HIGH); // LED on = WiFi connected
|
| 70 |
+
} else {
|
| 71 |
+
Serial.println(" β WiFi failed. USB Serial mode only.");
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
Serial.println("[READY] Listening for commands (WiFi + USB Serial)");
|
| 75 |
+
Serial.println("[USB] Send '1' via Serial to open gate");
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
void openGate(int durationSeconds) {
|
| 79 |
+
if (gateIsOpen) return; // Prevent double-open
|
| 80 |
+
|
| 81 |
+
gateIsOpen = true;
|
| 82 |
+
Serial.println("π’ GATE OPENING...");
|
| 83 |
+
gateServo.write(90);
|
| 84 |
+
|
| 85 |
+
// Blink LED while gate is open
|
| 86 |
+
for (int i = 0; i < durationSeconds * 2; i++) {
|
| 87 |
+
digitalWrite(LED_PIN, (i % 2 == 0) ? HIGH : LOW);
|
| 88 |
+
delay(500);
|
| 89 |
+
|
| 90 |
+
// Check for early close via serial
|
| 91 |
+
if (Serial.available() > 0) {
|
| 92 |
+
char cmd = Serial.read();
|
| 93 |
+
if (cmd == '0') {
|
| 94 |
+
Serial.println("β‘ Manual close command received");
|
| 95 |
+
break;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
Serial.println("π΄ GATE CLOSING...");
|
| 101 |
+
gateServo.write(0);
|
| 102 |
+
digitalWrite(LED_PIN, WiFi.status() == WL_CONNECTED ? HIGH : LOW);
|
| 103 |
+
gateIsOpen = false;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
HTTPClient http;
|
| 107 |
+
bool httpInitialized = false;
|
| 108 |
+
|
| 109 |
+
void pollBackend() {
|
| 110 |
+
if (WiFi.status() != WL_CONNECTED) return;
|
| 111 |
+
if (gateIsOpen) return;
|
| 112 |
+
|
| 113 |
+
if (!httpInitialized) {
|
| 114 |
+
http.setReuse(true);
|
| 115 |
+
httpInitialized = true;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
String url = String(BACKEND_URL) + "/gate/poll?location=" + GATE_LOCATION;
|
| 119 |
+
|
| 120 |
+
http.begin(url);
|
| 121 |
+
http.setTimeout(2000);
|
| 122 |
+
int httpCode = http.GET();
|
| 123 |
+
|
| 124 |
+
if (httpCode == 200) {
|
| 125 |
+
String response = http.getString();
|
| 126 |
+
|
| 127 |
+
// Parse JSON
|
| 128 |
+
StaticJsonDocument<512> doc;
|
| 129 |
+
DeserializationError error = deserializeJson(doc, response);
|
| 130 |
+
|
| 131 |
+
if (!error) {
|
| 132 |
+
const char* status = doc["status"];
|
| 133 |
+
|
| 134 |
+
if (strcmp(status, "command") == 0) {
|
| 135 |
+
const char* action = doc["command"]["action"];
|
| 136 |
+
int duration = doc["command"]["duration_seconds"] | 10;
|
| 137 |
+
const char* plate = doc["command"]["plate"] | "Unknown";
|
| 138 |
+
|
| 139 |
+
Serial.print("π‘ Cloud command: ");
|
| 140 |
+
Serial.print(action);
|
| 141 |
+
Serial.println("s");
|
| 142 |
+
|
| 143 |
+
if (strcmp(action, "open") == 0) {
|
| 144 |
+
openGate(duration);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
} else if (httpCode < 0) {
|
| 149 |
+
// If error (e.g. timeout), end and re-init next time
|
| 150 |
+
http.end();
|
| 151 |
+
httpInitialized = false;
|
| 152 |
+
}
|
| 153 |
+
// If 200 OK, we DO NOT call http.end() so the TLS socket stays alive!
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
void loop() {
|
| 157 |
+
// ββ Mode 1: USB Serial Commands βββββββββββββββββββββββ
|
| 158 |
+
if (Serial.available() > 0) {
|
| 159 |
+
char cmd = Serial.read();
|
| 160 |
+
if (cmd == '1') {
|
| 161 |
+
Serial.println("π USB command received!");
|
| 162 |
+
openGate(10);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// ββ Mode 2: WiFi Cloud Polling ββββββββββββββββββββββββ
|
| 167 |
+
unsigned long now = millis();
|
| 168 |
+
if (now - lastPollTime >= POLL_INTERVAL_MS) {
|
| 169 |
+
lastPollTime = now;
|
| 170 |
+
pollBackend();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Small delay to prevent busy-waiting
|
| 174 |
+
delay(10);
|
| 175 |
+
}
|
models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
models/car_plate_best.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:43a6f0f8e08e140a9c2958ae95aec5b2206a18e18343e74f328d1ae56fd06efa
|
| 3 |
+
size 40522154
|
models/model_ocr.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4bbff2d2eccb0b249cb783ad17fbdf1da9914813ad9738d3eb5555798e614acd
|
| 3 |
+
size 52068370
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.1.1
|
| 2 |
+
requests==2.32.3
|
| 3 |
+
python-dotenv==1.1.0
|
| 4 |
+
opencv-python-headless==4.11.0.86
|
| 5 |
+
gunicorn==23.0.0
|
routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routes package
|
routes/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
routes/__pycache__/admin_routes.cpython-311.pyc
ADDED
|
Binary file (9.45 kB). View file
|
|
|
routes/__pycache__/auth_routes.cpython-311.pyc
ADDED
|
Binary file (6.42 kB). View file
|
|
|
routes/__pycache__/camera_routes.cpython-311.pyc
ADDED
|
Binary file (4 kB). View file
|
|
|
routes/__pycache__/security_routes.cpython-311.pyc
ADDED
|
Binary file (7.58 kB). View file
|
|
|
routes/admin_routes.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
admin_routes.py
|
| 3 |
+
Admin dashboard β full management panel.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import (
|
| 7 |
+
Blueprint, render_template, request, session,
|
| 8 |
+
redirect, url_for, flash, jsonify
|
| 9 |
+
)
|
| 10 |
+
from services.api_service import (
|
| 11 |
+
get_inside_cars, get_entered_cars, get_left_cars, get_leaving_soon,
|
| 12 |
+
get_parking_history, get_parking_locations, get_parking_occupancy,
|
| 13 |
+
get_all_users,
|
| 14 |
+
get_my_vehicles, update_profile, add_vehicle, edit_vehicle,
|
| 15 |
+
delete_vehicle, weekly_refresh, confirm_cash_payment
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _require_admin():
|
| 22 |
+
if "token" not in session or session.get("role") != "admin":
|
| 23 |
+
return redirect(url_for("auth.login_page"))
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@admin_bp.before_request
|
| 28 |
+
def before():
|
| 29 |
+
result = _require_admin()
|
| 30 |
+
if result:
|
| 31 |
+
return result
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@admin_bp.route("/")
|
| 35 |
+
def dashboard():
|
| 36 |
+
token = session["token"]
|
| 37 |
+
occupancy = get_parking_occupancy(token)
|
| 38 |
+
locations = get_parking_locations(token)
|
| 39 |
+
inside = get_inside_cars(token)
|
| 40 |
+
return render_template(
|
| 41 |
+
"admin/dashboard.html",
|
| 42 |
+
user=session.get("user", {}),
|
| 43 |
+
occupancy=occupancy,
|
| 44 |
+
locations=locations,
|
| 45 |
+
inside_cars=inside,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@admin_bp.route("/vehicles")
|
| 50 |
+
def vehicles():
|
| 51 |
+
token = session["token"]
|
| 52 |
+
return render_template("admin/vehicles.html", user=session.get("user", {}))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@admin_bp.route("/history")
|
| 56 |
+
def history():
|
| 57 |
+
token = session["token"]
|
| 58 |
+
hist = get_parking_history(token)
|
| 59 |
+
return render_template("admin/history.html", user=session.get("user", {}), history=hist)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@admin_bp.route("/garage")
|
| 63 |
+
def garage():
|
| 64 |
+
token = session["token"]
|
| 65 |
+
locations = get_parking_locations(token)
|
| 66 |
+
occupancy = get_parking_occupancy(token)
|
| 67 |
+
inside = get_inside_cars(token)
|
| 68 |
+
entered = get_entered_cars(token)
|
| 69 |
+
left = get_left_cars(token)
|
| 70 |
+
leaving = get_leaving_soon(token)
|
| 71 |
+
return render_template(
|
| 72 |
+
"admin/garage.html",
|
| 73 |
+
user=session.get("user", {}),
|
| 74 |
+
locations=locations,
|
| 75 |
+
occupancy=occupancy,
|
| 76 |
+
inside_cars=inside,
|
| 77 |
+
entered_cars=entered,
|
| 78 |
+
left_cars=left,
|
| 79 |
+
leaving_cars=leaving,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@admin_bp.route("/users")
|
| 84 |
+
def users():
|
| 85 |
+
return render_template("admin/users.html", user=session.get("user", {}))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@admin_bp.route("/api/users")
|
| 89 |
+
def api_users():
|
| 90 |
+
return jsonify(get_all_users(session["token"]))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ββ AJAX API endpoints ββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
@admin_bp.route("/api/occupancy")
|
| 95 |
+
def api_occupancy():
|
| 96 |
+
return jsonify(get_parking_occupancy(session["token"]))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@admin_bp.route("/api/locations")
|
| 100 |
+
def api_locations():
|
| 101 |
+
return jsonify(get_parking_locations(session["token"]))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@admin_bp.route("/api/inside-cars")
|
| 105 |
+
def api_inside_cars():
|
| 106 |
+
return jsonify(get_inside_cars(session["token"]))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@admin_bp.route("/api/entered-cars")
|
| 110 |
+
def api_entered_cars():
|
| 111 |
+
return jsonify(get_entered_cars(session["token"]))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@admin_bp.route("/api/left-cars")
|
| 115 |
+
def api_left_cars():
|
| 116 |
+
return jsonify(get_left_cars(session["token"]))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@admin_bp.route("/api/leaving-soon")
|
| 120 |
+
def api_leaving_soon():
|
| 121 |
+
return jsonify(get_leaving_soon(session["token"]))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@admin_bp.route("/api/history")
|
| 125 |
+
def api_history():
|
| 126 |
+
return jsonify(get_parking_history(session["token"]))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@admin_bp.route("/api/vehicles/add", methods=["POST"])
|
| 130 |
+
def api_add_vehicle():
|
| 131 |
+
data = request.get_json() or {}
|
| 132 |
+
return jsonify(add_vehicle(session["token"], data))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@admin_bp.route("/api/vehicles/<vehicle_id>", methods=["PUT"])
|
| 136 |
+
def api_edit_vehicle(vehicle_id):
|
| 137 |
+
data = request.get_json() or {}
|
| 138 |
+
return jsonify(edit_vehicle(session["token"], vehicle_id, data))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@admin_bp.route("/api/vehicles/<vehicle_id>", methods=["DELETE"])
|
| 142 |
+
def api_delete_vehicle(vehicle_id):
|
| 143 |
+
return jsonify(delete_vehicle(session["token"], vehicle_id))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@admin_bp.route("/api/profile/update", methods=["PUT"])
|
| 147 |
+
def api_update_profile():
|
| 148 |
+
data = request.get_json() or {}
|
| 149 |
+
return jsonify(update_profile(session["token"], data))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@admin_bp.route("/api/cash-confirm", methods=["POST"])
|
| 153 |
+
def api_cash_confirm():
|
| 154 |
+
data = request.get_json() or {}
|
| 155 |
+
return jsonify(confirm_cash_payment(session["token"], data))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@admin_bp.route("/api/maintenance", methods=["POST"])
|
| 159 |
+
def api_maintenance():
|
| 160 |
+
return jsonify(weekly_refresh(session["token"]))
|
routes/auth_routes.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
auth_routes.py
|
| 3 |
+
Login / Register / Logout β redirects based on role.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import (
|
| 7 |
+
Blueprint, render_template, request, redirect,
|
| 8 |
+
url_for, session, flash
|
| 9 |
+
)
|
| 10 |
+
from services.api_service import login, register
|
| 11 |
+
|
| 12 |
+
auth_bp = Blueprint("auth", __name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@auth_bp.route("/")
|
| 16 |
+
def index():
|
| 17 |
+
if "token" in session:
|
| 18 |
+
role = session.get("role", "user")
|
| 19 |
+
if role == "admin":
|
| 20 |
+
return redirect(url_for("admin.dashboard"))
|
| 21 |
+
elif role == "security":
|
| 22 |
+
return redirect(url_for("security.dashboard"))
|
| 23 |
+
return redirect(url_for("auth.login_page"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@auth_bp.route("/login", methods=["GET", "POST"])
|
| 27 |
+
def login_page():
|
| 28 |
+
if request.method == "POST":
|
| 29 |
+
identifier = request.form.get("identifier", "").strip()
|
| 30 |
+
password = request.form.get("password", "").strip()
|
| 31 |
+
if not identifier or not password:
|
| 32 |
+
flash("Please fill in all fields.", "error")
|
| 33 |
+
return render_template("auth/login.html")
|
| 34 |
+
|
| 35 |
+
result = login(identifier, password)
|
| 36 |
+
|
| 37 |
+
if "error" in result:
|
| 38 |
+
flash(f"Login failed: {result['error']}", "error")
|
| 39 |
+
return render_template("auth/login.html")
|
| 40 |
+
|
| 41 |
+
# Extract token and role from response (handle different backend structures)
|
| 42 |
+
sess_data = result.get("session", {})
|
| 43 |
+
user_data = result.get("user", result) # Fallback to root for user data
|
| 44 |
+
|
| 45 |
+
token = sess_data.get("access_token") or result.get("access_token")
|
| 46 |
+
role = user_data.get("role") or result.get("role", "user")
|
| 47 |
+
is_staff = user_data.get("is_staff", result.get("is_staff", False))
|
| 48 |
+
|
| 49 |
+
if not token:
|
| 50 |
+
flash("Login failed: no token received from backend.", "error")
|
| 51 |
+
return render_template("auth/login.html")
|
| 52 |
+
|
| 53 |
+
# Only allow admin / security roles
|
| 54 |
+
if role not in ("admin", "security"):
|
| 55 |
+
flash("Access denied. This dashboard is for staff only.", "error")
|
| 56 |
+
return render_template("auth/login.html")
|
| 57 |
+
|
| 58 |
+
# Store session
|
| 59 |
+
session["token"] = token
|
| 60 |
+
session["refresh_token"] = sess_data.get("refresh_token", "")
|
| 61 |
+
session["role"] = role
|
| 62 |
+
session["is_staff"] = is_staff
|
| 63 |
+
session["user"] = user_data
|
| 64 |
+
|
| 65 |
+
if role == "admin":
|
| 66 |
+
return redirect(url_for("admin.dashboard"))
|
| 67 |
+
else:
|
| 68 |
+
return redirect(url_for("security.dashboard"))
|
| 69 |
+
|
| 70 |
+
return render_template("auth/login.html")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@auth_bp.route("/register", methods=["GET", "POST"])
|
| 74 |
+
def register_page():
|
| 75 |
+
if request.method == "POST":
|
| 76 |
+
payload = {
|
| 77 |
+
"first_name": request.form.get("first_name", "").strip(),
|
| 78 |
+
"last_name": request.form.get("last_name", "").strip(),
|
| 79 |
+
"username": request.form.get("username", "").strip(),
|
| 80 |
+
"phone": request.form.get("phone", "").strip(),
|
| 81 |
+
"password": request.form.get("password", "").strip(),
|
| 82 |
+
"email": request.form.get("email", "").strip(),
|
| 83 |
+
"nid": request.form.get("nid", "").strip(),
|
| 84 |
+
"role": request.form.get("role", "security").strip(),
|
| 85 |
+
}
|
| 86 |
+
if not payload["username"] or not payload["phone"] or not payload["password"] or not payload["email"]:
|
| 87 |
+
flash("Username, phone, email, and password are required.", "error")
|
| 88 |
+
return render_template("auth/register.html")
|
| 89 |
+
|
| 90 |
+
result = register(payload)
|
| 91 |
+
if "error" in result:
|
| 92 |
+
flash(f"Registration failed: {result['error']}", "error")
|
| 93 |
+
return render_template("auth/register.html")
|
| 94 |
+
|
| 95 |
+
flash("Registration successful! Please log in.", "success")
|
| 96 |
+
return redirect(url_for("auth.login_page"))
|
| 97 |
+
|
| 98 |
+
return render_template("auth/register.html")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@auth_bp.route("/logout")
|
| 102 |
+
def logout():
|
| 103 |
+
session.clear()
|
| 104 |
+
flash("Logged out.", "info")
|
| 105 |
+
return redirect(url_for("auth.login_page"))
|
routes/camera_routes.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
camera_routes.py
|
| 3 |
+
Camera start/stop and MJPEG video feed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import Blueprint, Response, jsonify, session, redirect, url_for
|
| 7 |
+
from services.camera_service import camera
|
| 8 |
+
|
| 9 |
+
camera_bp = Blueprint("camera", __name__, url_prefix="/camera")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@camera_bp.route("/start", methods=["POST"])
|
| 13 |
+
def start():
|
| 14 |
+
if "token" not in session:
|
| 15 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 16 |
+
try:
|
| 17 |
+
from flask import current_app
|
| 18 |
+
source = current_app.config.get("CAMERA_SOURCE", 0)
|
| 19 |
+
camera.start(source=source)
|
| 20 |
+
return jsonify({"status": "started"})
|
| 21 |
+
except RuntimeError as e:
|
| 22 |
+
return jsonify({"error": str(e)}), 500
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@camera_bp.route("/stop", methods=["POST"])
|
| 26 |
+
def stop():
|
| 27 |
+
if "token" not in session:
|
| 28 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 29 |
+
camera.stop()
|
| 30 |
+
return jsonify({"status": "stopped"})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@camera_bp.route("/status")
|
| 34 |
+
def status():
|
| 35 |
+
return jsonify({"running": camera.is_running})
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@camera_bp.route("/video_feed")
|
| 39 |
+
def video_feed():
|
| 40 |
+
if not camera.is_running:
|
| 41 |
+
return "Camera not running", 503
|
| 42 |
+
return Response(
|
| 43 |
+
camera.generate_mjpeg(),
|
| 44 |
+
mimetype="multipart/x-mixed-replace; boundary=frame",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@camera_bp.route("/capture")
|
| 49 |
+
def capture():
|
| 50 |
+
"""Return a single JPEG frame."""
|
| 51 |
+
if "token" not in session:
|
| 52 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 53 |
+
frame = camera.get_frame()
|
| 54 |
+
if frame is None:
|
| 55 |
+
return "No frame available", 503
|
| 56 |
+
return Response(frame, mimetype="image/jpeg")
|
| 57 |
+
|
| 58 |
+
@camera_bp.route("/latest_ocr")
|
| 59 |
+
def latest_ocr():
|
| 60 |
+
"""Return the latest OCR results from the live stream."""
|
| 61 |
+
if not camera.is_running:
|
| 62 |
+
return jsonify({"plate_key": None})
|
| 63 |
+
return jsonify({
|
| 64 |
+
"plate_key": getattr(camera, "latest_plate_key", None),
|
| 65 |
+
"ar_letters": getattr(camera, "latest_ar_letters", None),
|
| 66 |
+
"ar_numbers": getattr(camera, "latest_ar_numbers", None),
|
| 67 |
+
})
|
routes/security_routes.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
security_routes.py
|
| 3 |
+
Security guard dashboard β OCR scanning, gate control, live monitoring.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
from flask import (
|
| 8 |
+
Blueprint, render_template, request, session,
|
| 9 |
+
redirect, url_for, flash, jsonify
|
| 10 |
+
)
|
| 11 |
+
from services.api_service import (
|
| 12 |
+
predict, is_on_cooldown, get_inside_cars,
|
| 13 |
+
get_new_cars, trigger_gate_open, confirm_cash_payment,
|
| 14 |
+
get_parking_occupancy, get_gate_decision
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
security_bp = Blueprint("security", __name__, url_prefix="/security")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _require_security():
|
| 21 |
+
if "token" not in session or session.get("role") not in ("security", "admin"):
|
| 22 |
+
return redirect(url_for("auth.login_page"))
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@security_bp.before_request
|
| 27 |
+
def before():
|
| 28 |
+
result = _require_security()
|
| 29 |
+
if result:
|
| 30 |
+
return result
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@security_bp.route("/", methods=["GET", "POST"])
|
| 34 |
+
def dashboard():
|
| 35 |
+
token = session["token"]
|
| 36 |
+
|
| 37 |
+
# --- OCR Scanner Logic ---
|
| 38 |
+
result = None
|
| 39 |
+
if request.method == "POST":
|
| 40 |
+
event_type = request.form.get("event_type", "entry")
|
| 41 |
+
location = request.form.get("parking_location", "OPERA")
|
| 42 |
+
|
| 43 |
+
image_data = None
|
| 44 |
+
filename = "capture.jpg"
|
| 45 |
+
|
| 46 |
+
if "image" in request.files and request.files["image"].filename:
|
| 47 |
+
f = request.files["image"]
|
| 48 |
+
image_data = f.read()
|
| 49 |
+
filename = f.filename
|
| 50 |
+
elif request.form.get("camera_frame"):
|
| 51 |
+
b64 = request.form["camera_frame"]
|
| 52 |
+
if "," in b64:
|
| 53 |
+
b64 = b64.split(",", 1)[1]
|
| 54 |
+
image_data = base64.b64decode(b64)
|
| 55 |
+
filename = "camera_capture.jpg"
|
| 56 |
+
|
| 57 |
+
if not image_data:
|
| 58 |
+
flash("No image provided.", "error")
|
| 59 |
+
else:
|
| 60 |
+
# Send to backend to register event (Real Backend)
|
| 61 |
+
result = predict(image_data, filename, event_type, location, token)
|
| 62 |
+
|
| 63 |
+
# Backend plate check (fallback)
|
| 64 |
+
plate_info = result.get("plate_info") or result.get("plate") or {}
|
| 65 |
+
if isinstance(plate_info, dict):
|
| 66 |
+
plate_key = plate_info.get("plate_key", "") or plate_info.get("plate_ar", "")
|
| 67 |
+
elif isinstance(plate_info, str):
|
| 68 |
+
plate_key = plate_info
|
| 69 |
+
else:
|
| 70 |
+
plate_key = ""
|
| 71 |
+
|
| 72 |
+
if plate_key and is_on_cooldown(plate_key):
|
| 73 |
+
result["cooldown_warning"] = True
|
| 74 |
+
|
| 75 |
+
# Gate is now triggered exclusively by the frontend dashboard to avoid double-opens
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
# --- Dashboard Logic ---
|
| 79 |
+
occupancy = get_parking_occupancy(token)
|
| 80 |
+
inside = get_inside_cars(token)
|
| 81 |
+
|
| 82 |
+
# If AJAX request from the scanner, return JSON
|
| 83 |
+
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
| 84 |
+
return jsonify(result)
|
| 85 |
+
|
| 86 |
+
return render_template(
|
| 87 |
+
"security/dashboard.html",
|
| 88 |
+
user=session.get("user", {}),
|
| 89 |
+
occupancy=occupancy,
|
| 90 |
+
inside_cars=inside,
|
| 91 |
+
result=result
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@security_bp.route("/api/new-cars")
|
| 96 |
+
def api_new_cars():
|
| 97 |
+
"""AJAX endpoint for live polling."""
|
| 98 |
+
token = session.get("token")
|
| 99 |
+
return jsonify(get_new_cars(token))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@security_bp.route("/api/inside-cars")
|
| 103 |
+
def api_inside_cars():
|
| 104 |
+
token = session.get("token")
|
| 105 |
+
return jsonify(get_inside_cars(token))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@security_bp.route("/api/occupancy")
|
| 109 |
+
def api_occupancy():
|
| 110 |
+
token = session.get("token")
|
| 111 |
+
return jsonify(get_parking_occupancy(token))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@security_bp.route("/api/cash-confirm", methods=["POST"])
|
| 115 |
+
def api_cash_confirm():
|
| 116 |
+
"""Security confirms cash payment and triggers exit closure."""
|
| 117 |
+
token = session.get("token")
|
| 118 |
+
data = request.get_json() or {}
|
| 119 |
+
|
| 120 |
+
# 1. Confirm the cash payment in the backend
|
| 121 |
+
result = confirm_cash_payment(token, data)
|
| 122 |
+
|
| 123 |
+
# 2. Trigger an explicit gate decision (exit) to archive the session
|
| 124 |
+
# This prevents the need for a second scan to mark the car as "left"
|
| 125 |
+
plate_key = data.get("plate_key") or data.get("plate_arabic")
|
| 126 |
+
location = data.get("parking_location") or "OPERA"
|
| 127 |
+
|
| 128 |
+
if plate_key and not result.get("error"):
|
| 129 |
+
# We fire and forget or just log the decision to ensure backend archives the session
|
| 130 |
+
get_gate_decision(token, {
|
| 131 |
+
"plate_key": plate_key,
|
| 132 |
+
"event_type": "exit",
|
| 133 |
+
"parking_location": location
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
return jsonify(result)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@security_bp.route("/api/gate-open", methods=["POST"])
|
| 140 |
+
def api_gate_open():
|
| 141 |
+
"""Manual gate open trigger."""
|
| 142 |
+
return jsonify(trigger_gate_open())
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@security_bp.route("/api/live-ocr", methods=["POST"])
|
| 146 |
+
def api_live_ocr():
|
| 147 |
+
"""Lightweight OCR endpoint for live browser frames."""
|
| 148 |
+
data = request.get_json() or {}
|
| 149 |
+
b64 = data.get("image")
|
| 150 |
+
if not b64:
|
| 151 |
+
return jsonify({"error": "No image"}), 400
|
| 152 |
+
|
| 153 |
+
if "," in b64:
|
| 154 |
+
b64 = b64.split(",", 1)[1]
|
| 155 |
+
image_data = base64.b64decode(b64)
|
| 156 |
+
|
| 157 |
+
# Call backend but with ocr_scan event type
|
| 158 |
+
result = predict(image_data, "live_frame.jpg", event_type="ocr_scan", token=session.get("token"))
|
| 159 |
+
return jsonify(result)
|
scratch/test_railway.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
url = "https://railway.com/project/6030af2c-06da-4c86-9ef7-aa68fedee8cd/service/68f17b61-afdb-42e7-a374-1049dd196786?environmentId=be637a41-505e-4f28-b149-93e6af05d4d0/auth/login"
|
| 4 |
+
r = requests.post(url, json={"identifier": "EzzeldeenQutbS", "password": "password"}, headers={"Accept": "application/json"})
|
| 5 |
+
print(r.status_code)
|
| 6 |
+
try:
|
| 7 |
+
print(r.json())
|
| 8 |
+
except:
|
| 9 |
+
print(r.text[:200])
|
services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (173 Bytes). View file
|
|
|
services/__pycache__/api_service.cpython-311.pyc
ADDED
|
Binary file (21.8 kB). View file
|
|
|
services/__pycache__/camera_service.cpython-311.pyc
ADDED
|
Binary file (4.39 kB). View file
|
|
|
services/__pycache__/ocr_service.cpython-311.pyc
ADDED
|
Binary file (5.43 kB). View file
|
|
|
services/api_service.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
api_service.py
|
| 3 |
+
Thin wrapper around the Railway backend API.
|
| 4 |
+
All HTTP calls to the remote backend go through here.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import time
|
| 9 |
+
from flask import current_app
|
| 10 |
+
|
| 11 |
+
# ββ Cooldown tracking ββββββββββββββββββββββββββββββββββββββββ
|
| 12 |
+
_plate_last_seen: dict[str, float] = {}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
def _base_url() -> str:
|
| 18 |
+
try:
|
| 19 |
+
from flask import current_app
|
| 20 |
+
return current_app.config["BACKEND_API_URL"].rstrip("/")
|
| 21 |
+
except:
|
| 22 |
+
return os.getenv("BACKEND_API_URL", "https://ain-el-aql-backend-production.up.railway.app").rstrip("/")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _headers(token: str | None = None) -> dict:
|
| 26 |
+
h = {"Accept": "application/json"}
|
| 27 |
+
if token:
|
| 28 |
+
h["Authorization"] = f"Bearer {token}"
|
| 29 |
+
return h
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalize_arabic_plate(text: str) -> str:
|
| 33 |
+
"""Converts Hindi digits to Western digits and handles formatting."""
|
| 34 |
+
if not text: return ""
|
| 35 |
+
hindi_to_western = {
|
| 36 |
+
'Ω ': '0', 'Ω‘': '1', 'Ω’': '2', 'Ω£': '3', 'Ω€': '4',
|
| 37 |
+
'Ω₯': '5', 'Ω¦': '6', 'Ω§': '7', 'Ω¨': '8', 'Ω©': '9'
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Convert digits
|
| 41 |
+
res = ""
|
| 42 |
+
for char in text:
|
| 43 |
+
res += hindi_to_western.get(char, char)
|
| 44 |
+
|
| 45 |
+
if '|' in res:
|
| 46 |
+
parts = res.split('|')
|
| 47 |
+
letters = parts[0].strip()
|
| 48 |
+
numbers = parts[1].strip().replace(" ", "")
|
| 49 |
+
# Reverse numbers for correct LTR display in the browser
|
| 50 |
+
return f"{letters} | {numbers[::-1]}"
|
| 51 |
+
|
| 52 |
+
return res
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def extract_match_key(text: str) -> str:
|
| 56 |
+
"""
|
| 57 |
+
Extracts alphanumeric characters and sorts them (Letters then Numbers)
|
| 58 |
+
to ensure 'SQR 777' and '777 SQR' generate the same key 'SQR777'.
|
| 59 |
+
"""
|
| 60 |
+
if not text: return ""
|
| 61 |
+
text = text.replace(" ", "").replace("|", "").replace("-", "")
|
| 62 |
+
|
| 63 |
+
import re
|
| 64 |
+
letters = "".join(re.findall(r'[a-zA-Z\u0600-\u06FF]+', text))
|
| 65 |
+
numbers = "".join(re.findall(r'[0-9]+', text))
|
| 66 |
+
|
| 67 |
+
# Egyptian system usually prefers Letters then Numbers
|
| 68 |
+
return f"{letters}{numbers}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
def check_health() -> dict:
|
| 73 |
+
try:
|
| 74 |
+
r = requests.get(f"{_base_url()}/health", timeout=5)
|
| 75 |
+
return r.json()
|
| 76 |
+
except Exception as e:
|
| 77 |
+
return {"status": "error", "detail": str(e)}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def check_supabase_health() -> dict:
|
| 81 |
+
try:
|
| 82 |
+
r = requests.get(f"{_base_url()}/supabase/health", timeout=5)
|
| 83 |
+
return r.json()
|
| 84 |
+
except Exception as e:
|
| 85 |
+
return {"status": "error", "detail": str(e)}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
def login(identifier: str, password: str) -> dict:
|
| 90 |
+
"""POST /auth/login β returns user + session."""
|
| 91 |
+
try:
|
| 92 |
+
r = requests.post(
|
| 93 |
+
f"{_base_url()}/auth/login",
|
| 94 |
+
json={"identifier": identifier, "password": password},
|
| 95 |
+
headers=_headers(),
|
| 96 |
+
timeout=30,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if r.status_code != 200:
|
| 100 |
+
try:
|
| 101 |
+
error_msg = r.json().get("detail", r.json().get("error", "Unknown error"))
|
| 102 |
+
except:
|
| 103 |
+
error_msg = f"Error {r.status_code}: {r.text[:100]}"
|
| 104 |
+
return {"error": error_msg}
|
| 105 |
+
|
| 106 |
+
return r.json()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
return {"error": str(e)}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def register(payload: dict) -> dict:
|
| 112 |
+
"""POST /auth/register"""
|
| 113 |
+
try:
|
| 114 |
+
r = requests.post(
|
| 115 |
+
f"{_base_url()}/auth/register",
|
| 116 |
+
json=payload,
|
| 117 |
+
headers=_headers(),
|
| 118 |
+
timeout=10,
|
| 119 |
+
)
|
| 120 |
+
return r.json()
|
| 121 |
+
except Exception as e:
|
| 122 |
+
return {"error": str(e)}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ββ Predict / OCR ββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
def predict(image_bytes: bytes, filename: str, event_type: str = "entry",
|
| 127 |
+
location: str = "OPERA", token: str | None = None) -> dict:
|
| 128 |
+
"""
|
| 129 |
+
POST /process-image (Railway) β send image for OCR.
|
| 130 |
+
Then optionally call /gate/decision to get registration/gate status.
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
# 1. Get OCR from the Main Engine
|
| 134 |
+
# Backend expects 'image' field for the file, and 'camera_source'
|
| 135 |
+
files = {"image": (filename, image_bytes, "image/jpeg")}
|
| 136 |
+
data = {
|
| 137 |
+
"camera_source": location,
|
| 138 |
+
"parking_location": location,
|
| 139 |
+
"event_type": event_type
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
ocr_resp = requests.post(
|
| 143 |
+
f"{_base_url()}/predict",
|
| 144 |
+
files=files,
|
| 145 |
+
data=data,
|
| 146 |
+
headers=_headers(token) if token else _headers(),
|
| 147 |
+
timeout=30,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
if ocr_resp.status_code != 200:
|
| 151 |
+
return {"error": f"Backend OCR failed: {ocr_resp.text[:100]}"}
|
| 152 |
+
|
| 153 |
+
resp = ocr_resp.json()
|
| 154 |
+
|
| 155 |
+
# 2. Map OCR results (Robust mapping)
|
| 156 |
+
det = resp.get("detection") or resp
|
| 157 |
+
urls = resp.get("urls") or resp.get("images") or {}
|
| 158 |
+
|
| 159 |
+
# Try different possible plate field names including new backend format
|
| 160 |
+
plate_info_dict = resp.get("plate_info", {})
|
| 161 |
+
plate_text = det.get("plate_text") or det.get("plate_ar") or det.get("plate") or plate_info_dict.get("arabic") or ""
|
| 162 |
+
en_text = det.get("en_text") or det.get("plate_en") or plate_info_dict.get("english") or ""
|
| 163 |
+
|
| 164 |
+
if plate_text or en_text:
|
| 165 |
+
normalized_ar = normalize_arabic_plate(plate_text)
|
| 166 |
+
match_key = extract_match_key(en_text) or extract_match_key(normalized_ar)
|
| 167 |
+
|
| 168 |
+
# Use the registration info directly if the backend already provided it
|
| 169 |
+
# Different backends might use 'app_registration' or top-level 'matched'
|
| 170 |
+
reg_info = resp.get("app_registration") or resp.get("registration") or {}
|
| 171 |
+
is_registered = False
|
| 172 |
+
if isinstance(reg_info, dict):
|
| 173 |
+
is_registered = reg_info.get("is_registered", resp.get("matched", False))
|
| 174 |
+
elif isinstance(reg_info, bool):
|
| 175 |
+
is_registered = reg_info
|
| 176 |
+
|
| 177 |
+
resp["matched"] = is_registered
|
| 178 |
+
resp["plate_text_ar"] = normalized_ar
|
| 179 |
+
resp["plate_info"] = {
|
| 180 |
+
"plate_ar": normalized_ar,
|
| 181 |
+
"plate_en": en_text,
|
| 182 |
+
"plate_key": match_key,
|
| 183 |
+
"confidence": det.get("confidence", det.get("score", 0))
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
# Map Images
|
| 187 |
+
admin_page_data = resp.get("admin_page", {})
|
| 188 |
+
if "admin_page" not in resp:
|
| 189 |
+
resp["admin_page"] = {}
|
| 190 |
+
|
| 191 |
+
# If backend returns base64, create a data URL
|
| 192 |
+
if admin_page_data.get("annotated_image_base64"):
|
| 193 |
+
b64 = admin_page_data["annotated_image_base64"]
|
| 194 |
+
# Sometimes it might already have the prefix
|
| 195 |
+
if b64.startswith("data:"):
|
| 196 |
+
resp["admin_page"]["processed_image_url"] = b64
|
| 197 |
+
else:
|
| 198 |
+
resp["admin_page"]["processed_image_url"] = f"data:image/jpeg;base64,{b64}"
|
| 199 |
+
else:
|
| 200 |
+
resp["admin_page"]["processed_image_url"] = urls.get("processed") or urls.get("annotated") or ""
|
| 201 |
+
|
| 202 |
+
resp["admin_page"]["raw_image_url"] = urls.get("raw") or urls.get("original") or ""
|
| 203 |
+
|
| 204 |
+
# 3. Call Gate Decision (Business Logic)
|
| 205 |
+
if "gate_decision" not in resp:
|
| 206 |
+
try:
|
| 207 |
+
decision_payload = {
|
| 208 |
+
"plate_key": resp["plate_info"]["plate_key"],
|
| 209 |
+
"event_type": event_type,
|
| 210 |
+
"parking_location": location
|
| 211 |
+
}
|
| 212 |
+
dr = requests.post(
|
| 213 |
+
f"{_base_url()}/gate/decision",
|
| 214 |
+
json=decision_payload,
|
| 215 |
+
headers=_headers(token) if token else _headers(),
|
| 216 |
+
timeout=10
|
| 217 |
+
)
|
| 218 |
+
if dr.status_code == 200:
|
| 219 |
+
decision_data = dr.json()
|
| 220 |
+
resp.update(decision_data)
|
| 221 |
+
except Exception as e:
|
| 222 |
+
resp["backend_warning"] = f"Gate decision failed: {str(e)}"
|
| 223 |
+
|
| 224 |
+
return resp
|
| 225 |
+
except Exception as e:
|
| 226 |
+
return {"error": str(e)}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def is_on_cooldown(plate_key: str) -> bool:
|
| 230 |
+
"""Returns True if plate was scanned within PLATE_COOLDOWN_SECONDS."""
|
| 231 |
+
cooldown = current_app.config["PLATE_COOLDOWN_SECONDS"]
|
| 232 |
+
now = time.time()
|
| 233 |
+
if plate_key in _plate_last_seen:
|
| 234 |
+
if now - _plate_last_seen[plate_key] < cooldown:
|
| 235 |
+
return True
|
| 236 |
+
_plate_last_seen[plate_key] = now
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ββ Parking / Events (staff endpoints) βββββββββββββββββββββ
|
| 241 |
+
def get_entered_cars(token: str) -> dict:
|
| 242 |
+
try:
|
| 243 |
+
r = requests.get(f"{_base_url()}/events/entered-cars",
|
| 244 |
+
headers=_headers(token), timeout=30)
|
| 245 |
+
return r.json()
|
| 246 |
+
except Exception as e:
|
| 247 |
+
return {"error": str(e)}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def get_inside_cars(token: str) -> dict:
|
| 251 |
+
try:
|
| 252 |
+
r = requests.get(f"{_base_url()}/events/inside-cars",
|
| 253 |
+
headers=_headers(token), timeout=30)
|
| 254 |
+
return r.json()
|
| 255 |
+
except Exception as e:
|
| 256 |
+
return {"error": str(e)}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def get_left_cars(token: str) -> dict:
|
| 260 |
+
try:
|
| 261 |
+
r = requests.get(f"{_base_url()}/events/left-cars",
|
| 262 |
+
headers=_headers(token), timeout=30)
|
| 263 |
+
return r.json()
|
| 264 |
+
except Exception as e:
|
| 265 |
+
return {"error": str(e)}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def get_leaving_soon(token: str) -> dict:
|
| 269 |
+
try:
|
| 270 |
+
r = requests.get(f"{_base_url()}/events/leaving-cars-within-5-minutes",
|
| 271 |
+
headers=_headers(token), timeout=30)
|
| 272 |
+
return r.json()
|
| 273 |
+
except Exception as e:
|
| 274 |
+
return {"error": str(e)}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def get_new_cars(token: str) -> dict:
|
| 278 |
+
"""GET /events/new-cars β staff-only real-time polling feed."""
|
| 279 |
+
try:
|
| 280 |
+
r = requests.get(f"{_base_url()}/events/new-cars",
|
| 281 |
+
headers=_headers(token), timeout=30)
|
| 282 |
+
return r.json()
|
| 283 |
+
except Exception as e:
|
| 284 |
+
return {"error": str(e)}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def get_parking_history(token: str) -> dict:
|
| 288 |
+
try:
|
| 289 |
+
r = requests.get(f"{_base_url()}/parking/history",
|
| 290 |
+
headers=_headers(token), timeout=30)
|
| 291 |
+
return r.json()
|
| 292 |
+
except Exception as e:
|
| 293 |
+
return {"error": str(e)}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def get_all_users(token: str) -> dict:
|
| 297 |
+
try:
|
| 298 |
+
r = requests.get(f"{_base_url()}/users",
|
| 299 |
+
headers=_headers(token), timeout=30)
|
| 300 |
+
return r.json()
|
| 301 |
+
except Exception as e:
|
| 302 |
+
return {"error": str(e)}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def get_parking_locations(token: str) -> dict:
|
| 306 |
+
try:
|
| 307 |
+
r = requests.get(f"{_base_url()}/parking/locations",
|
| 308 |
+
headers=_headers(token), timeout=30)
|
| 309 |
+
return r.json()
|
| 310 |
+
except Exception as e:
|
| 311 |
+
return {"error": str(e)}
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def get_parking_occupancy(token: str) -> dict:
|
| 315 |
+
try:
|
| 316 |
+
r = requests.get(f"{_base_url()}/parking/occupancy",
|
| 317 |
+
headers=_headers(token), timeout=30)
|
| 318 |
+
return r.json()
|
| 319 |
+
except Exception as e:
|
| 320 |
+
return {"error": str(e)}
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ββ Vehicles βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 324 |
+
def get_my_vehicles(token: str) -> dict:
|
| 325 |
+
try:
|
| 326 |
+
r = requests.get(f"{_base_url()}/vehicles/my",
|
| 327 |
+
headers=_headers(token), timeout=30)
|
| 328 |
+
return r.json()
|
| 329 |
+
except Exception as e:
|
| 330 |
+
return {"error": str(e)}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def add_vehicle(token: str, payload: dict) -> dict:
|
| 334 |
+
try:
|
| 335 |
+
r = requests.post(f"{_base_url()}/vehicles/add",
|
| 336 |
+
json=payload, headers=_headers(token), timeout=30)
|
| 337 |
+
return r.json()
|
| 338 |
+
except Exception as e:
|
| 339 |
+
return {"error": str(e)}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def edit_vehicle(token: str, vehicle_id: str, payload: dict) -> dict:
|
| 343 |
+
try:
|
| 344 |
+
r = requests.put(f"{_base_url()}/vehicles/{vehicle_id}",
|
| 345 |
+
json=payload, headers=_headers(token), timeout=30)
|
| 346 |
+
return r.json()
|
| 347 |
+
except Exception as e:
|
| 348 |
+
return {"error": str(e)}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def delete_vehicle(token: str, vehicle_id: str) -> dict:
|
| 352 |
+
try:
|
| 353 |
+
r = requests.delete(f"{_base_url()}/vehicles/{vehicle_id}",
|
| 354 |
+
headers=_headers(token), timeout=30)
|
| 355 |
+
return r.json()
|
| 356 |
+
except Exception as e:
|
| 357 |
+
return {"error": str(e)}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ββ Profile ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 361 |
+
def update_profile(token: str, payload: dict) -> dict:
|
| 362 |
+
try:
|
| 363 |
+
r = requests.put(f"{_base_url()}/profile/update",
|
| 364 |
+
json=payload, headers=_headers(token), timeout=30)
|
| 365 |
+
return r.json()
|
| 366 |
+
except Exception as e:
|
| 367 |
+
return {"error": str(e)}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ββ Payments βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 371 |
+
def confirm_cash_payment(token: str, payload: dict) -> dict:
|
| 372 |
+
"""POST /payments/manual/cash-confirm β staff-only."""
|
| 373 |
+
try:
|
| 374 |
+
r = requests.post(f"{_base_url()}/payments/manual/cash-confirm",
|
| 375 |
+
json=payload, headers=_headers(token), timeout=30)
|
| 376 |
+
return r.json()
|
| 377 |
+
except Exception as e:
|
| 378 |
+
return {"error": str(e)}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def get_gate_decision(token: str, payload: dict) -> dict:
|
| 382 |
+
try:
|
| 383 |
+
r = requests.post(f"{_base_url()}/gate/decision",
|
| 384 |
+
json=payload, headers=_headers(token), timeout=30)
|
| 385 |
+
return r.json()
|
| 386 |
+
except Exception as e:
|
| 387 |
+
return {"error": str(e)}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ββ Gate hardware (ESP) ββββββββββββββββββββββββββββββββββββββ
|
| 391 |
+
def trigger_gate_open(plate: str = "", location: str = "OPERA") -> dict:
|
| 392 |
+
"""Queue a gate-open command on the cloud backend for ESP32 devices to poll."""
|
| 393 |
+
try:
|
| 394 |
+
r = requests.post(
|
| 395 |
+
f"{_base_url()}/gate/trigger",
|
| 396 |
+
json={
|
| 397 |
+
"parking_location": location,
|
| 398 |
+
"duration_seconds": 10,
|
| 399 |
+
"plate": plate,
|
| 400 |
+
},
|
| 401 |
+
timeout=5,
|
| 402 |
+
)
|
| 403 |
+
return {"status": "sent", "backend_response": r.json()}
|
| 404 |
+
except Exception as e:
|
| 405 |
+
return {"status": "error", "detail": str(e)}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ββ Admin maintenance ββββββββββββββββββββββββββββββββββββββββ
|
| 409 |
+
def weekly_refresh(token: str) -> dict:
|
| 410 |
+
try:
|
| 411 |
+
r = requests.post(f"{_base_url()}/admin/maintenance/weekly-refresh",
|
| 412 |
+
headers=_headers(token), timeout=15)
|
| 413 |
+
return r.json()
|
| 414 |
+
except Exception as e:
|
| 415 |
+
return {"error": str(e)}
|
services/camera_service.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
camera_service.py
|
| 3 |
+
Manages OpenCV camera capture. Start/Stop on demand (not continuous).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import cv2
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CameraService:
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self._cap = None
|
| 15 |
+
self._running = False
|
| 16 |
+
self._lock = threading.Lock()
|
| 17 |
+
self._frame = None
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def is_running(self) -> bool:
|
| 21 |
+
return self._running
|
| 22 |
+
|
| 23 |
+
def start(self, source: int = 0):
|
| 24 |
+
with self._lock:
|
| 25 |
+
if self._running:
|
| 26 |
+
return
|
| 27 |
+
self._cap = cv2.VideoCapture(source)
|
| 28 |
+
if not self._cap.isOpened():
|
| 29 |
+
raise RuntimeError("Cannot open camera")
|
| 30 |
+
self._running = True
|
| 31 |
+
threading.Thread(target=self._capture_loop, daemon=True).start()
|
| 32 |
+
|
| 33 |
+
def stop(self):
|
| 34 |
+
with self._lock:
|
| 35 |
+
self._running = False
|
| 36 |
+
if self._cap:
|
| 37 |
+
self._cap.release()
|
| 38 |
+
self._cap = None
|
| 39 |
+
self._frame = None
|
| 40 |
+
|
| 41 |
+
def _capture_loop(self):
|
| 42 |
+
while self._running:
|
| 43 |
+
if self._cap and self._cap.isOpened():
|
| 44 |
+
ok, frame = self._cap.read()
|
| 45 |
+
if ok:
|
| 46 |
+
self._frame = frame
|
| 47 |
+
time.sleep(0.033) # ~30 fps
|
| 48 |
+
|
| 49 |
+
def get_frame(self):
|
| 50 |
+
"""Return the latest frame as JPEG bytes, or None."""
|
| 51 |
+
if self._frame is None:
|
| 52 |
+
return None
|
| 53 |
+
ok, buf = cv2.imencode(".jpg", self._frame)
|
| 54 |
+
return buf.tobytes() if ok else None
|
| 55 |
+
|
| 56 |
+
def get_frame_raw(self):
|
| 57 |
+
"""Return raw numpy frame for processing."""
|
| 58 |
+
return self._frame.copy() if self._frame is not None else None
|
| 59 |
+
|
| 60 |
+
def generate_mjpeg(self):
|
| 61 |
+
"""Generator for MJPEG streaming."""
|
| 62 |
+
while self._running:
|
| 63 |
+
jpeg = self.get_frame()
|
| 64 |
+
if jpeg:
|
| 65 |
+
yield (
|
| 66 |
+
b"--frame\r\n"
|
| 67 |
+
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
|
| 68 |
+
)
|
| 69 |
+
time.sleep(0.066) # ~15 fps for streaming
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Singleton
|
| 73 |
+
camera = CameraService()
|
services/ocr_service.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local OCR service disabled.
|
| 2 |
+
# All processing is handled by the remote Railway backend.
|
services/supabase_client.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client, Client
|
| 3 |
+
from config import Config
|
| 4 |
+
|
| 5 |
+
def get_supabase() -> Client:
|
| 6 |
+
url: str = os.environ.get("SUPABASE_URL", "https://mymdiyxzrdtfdrgpgqow.supabase.co")
|
| 7 |
+
key: str = os.environ.get("SUPABASE_KEY", "your-supabase-key")
|
| 8 |
+
return create_client(url, key)
|
| 9 |
+
|
| 10 |
+
db = get_supabase()
|
static/assets/cctv_logo.png
ADDED
|
Git LFS Details
|
static/css/style.css
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
Ain El Aql β Dark CCTV-Style Dashboard
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
| 6 |
+
|
| 7 |
+
/* ββ CSS Variables βββββββββββββββββββββββββββββββββββββββββββ */
|
| 8 |
+
:root {
|
| 9 |
+
--bg-primary: #0a0e17;
|
| 10 |
+
--bg-secondary: #111827;
|
| 11 |
+
--bg-card: #1a2236;
|
| 12 |
+
--bg-card-hover: #1f2a42;
|
| 13 |
+
--bg-input: #0d1321;
|
| 14 |
+
--border-color: #2a3654;
|
| 15 |
+
--border-glow: #3b82f6;
|
| 16 |
+
--text-primary: #e2e8f0;
|
| 17 |
+
--text-secondary: #94a3b8;
|
| 18 |
+
--text-muted: #64748b;
|
| 19 |
+
--accent-blue: #3b82f6;
|
| 20 |
+
--accent-cyan: #06b6d4;
|
| 21 |
+
--accent-green: #10b981;
|
| 22 |
+
--accent-red: #ef4444;
|
| 23 |
+
--accent-amber: #f59e0b;
|
| 24 |
+
--accent-purple: #8b5cf6;
|
| 25 |
+
--gradient-blue: linear-gradient(135deg, #3b82f6, #06b6d4);
|
| 26 |
+
--gradient-green: linear-gradient(135deg, #10b981, #34d399);
|
| 27 |
+
--gradient-red: linear-gradient(135deg, #ef4444, #f87171);
|
| 28 |
+
--gradient-purple: linear-gradient(135deg, #8b5cf6, #a78bfa);
|
| 29 |
+
--shadow-sm: 0 1px 3px rgba(0,0,0,.4);
|
| 30 |
+
--shadow-md: 0 4px 14px rgba(0,0,0,.5);
|
| 31 |
+
--shadow-lg: 0 10px 30px rgba(0,0,0,.6);
|
| 32 |
+
--shadow-glow-blue: 0 0 20px rgba(59,130,246,.15);
|
| 33 |
+
--shadow-glow-green: 0 0 20px rgba(16,185,129,.15);
|
| 34 |
+
--radius-sm: 8px;
|
| 35 |
+
--radius-md: 12px;
|
| 36 |
+
--radius-lg: 16px;
|
| 37 |
+
--radius-xl: 20px;
|
| 38 |
+
--transition: all .25s cubic-bezier(.4,0,.2,1);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* ββ Reset & Base ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 42 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 43 |
+
html { font-size: 15px; scroll-behavior: smooth; }
|
| 44 |
+
body {
|
| 45 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 46 |
+
background: var(--bg-primary);
|
| 47 |
+
color: var(--text-primary);
|
| 48 |
+
min-height: 100vh;
|
| 49 |
+
line-height: 1.6;
|
| 50 |
+
-webkit-font-smoothing: antialiased;
|
| 51 |
+
}
|
| 52 |
+
a { color: var(--accent-cyan); text-decoration: none; transition: var(--transition); }
|
| 53 |
+
a:hover { color: var(--accent-blue); }
|
| 54 |
+
|
| 55 |
+
/* ββ Scrollbar βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 56 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 57 |
+
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
| 58 |
+
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
| 59 |
+
::-webkit-scrollbar-thumb:hover { background: var(--accent-blue); }
|
| 60 |
+
|
| 61 |
+
/* ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 62 |
+
.app-layout { display: flex; min-height: 100vh; }
|
| 63 |
+
.sidebar {
|
| 64 |
+
width: 260px; background: var(--bg-secondary);
|
| 65 |
+
border-right: 1px solid var(--border-color);
|
| 66 |
+
display: flex; flex-direction: column;
|
| 67 |
+
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
|
| 68 |
+
transition: var(--transition);
|
| 69 |
+
}
|
| 70 |
+
.sidebar-header {
|
| 71 |
+
padding: 1.5rem; border-bottom: 1px solid var(--border-color);
|
| 72 |
+
display: flex; align-items: center; gap: .75rem;
|
| 73 |
+
}
|
| 74 |
+
.sidebar-logo {
|
| 75 |
+
width: 40px; height: 40px; border-radius: var(--radius-sm);
|
| 76 |
+
background: var(--gradient-blue); display: grid; place-items: center;
|
| 77 |
+
font-weight: 800; font-size: 1.1rem; color: #fff;
|
| 78 |
+
}
|
| 79 |
+
.sidebar-title { font-weight: 700; font-size: 1rem; }
|
| 80 |
+
.sidebar-subtitle { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .08em; }
|
| 81 |
+
.sidebar-nav { flex: 1; padding: 1rem .75rem; display: flex; flex-direction: column; gap: 4px; }
|
| 82 |
+
.nav-link {
|
| 83 |
+
display: flex; align-items: center; gap: .75rem;
|
| 84 |
+
padding: .7rem 1rem; border-radius: var(--radius-sm);
|
| 85 |
+
color: var(--text-secondary); font-size: .875rem; font-weight: 500;
|
| 86 |
+
transition: var(--transition); cursor: pointer; border: none; background: none; width: 100%; text-align: left;
|
| 87 |
+
}
|
| 88 |
+
.nav-link:hover { background: var(--bg-card); color: var(--text-primary); }
|
| 89 |
+
.nav-link.active { background: rgba(59,130,246,.12); color: var(--accent-blue); border-left: 3px solid var(--accent-blue); }
|
| 90 |
+
.nav-link .icon { width: 20px; text-align: center; font-size: 1rem; }
|
| 91 |
+
.sidebar-footer { padding: 1rem; border-top: 1px solid var(--border-color); }
|
| 92 |
+
.user-badge {
|
| 93 |
+
display: flex; align-items: center; gap: .75rem;
|
| 94 |
+
padding: .5rem .75rem; border-radius: var(--radius-sm);
|
| 95 |
+
background: var(--bg-card);
|
| 96 |
+
}
|
| 97 |
+
.user-avatar {
|
| 98 |
+
width: 36px; height: 36px; border-radius: 50%;
|
| 99 |
+
background: var(--gradient-purple); display: grid; place-items: center;
|
| 100 |
+
font-weight: 700; font-size: .8rem; color: #fff;
|
| 101 |
+
}
|
| 102 |
+
.user-name { font-size: .8rem; font-weight: 600; }
|
| 103 |
+
.user-role { font-size: .65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
|
| 104 |
+
.main-content { flex: 1; margin-left: 260px; padding: 2rem; }
|
| 105 |
+
.page-header { margin-bottom: 2rem; }
|
| 106 |
+
.page-title { font-size: 1.75rem; font-weight: 800; letter-spacing: -.02em; }
|
| 107 |
+
.page-subtitle { color: var(--text-muted); font-size: .875rem; margin-top: .25rem; }
|
| 108 |
+
|
| 109 |
+
/* ββ Cards βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 110 |
+
.card {
|
| 111 |
+
background: var(--bg-card); border: 1px solid var(--border-color);
|
| 112 |
+
border-radius: var(--radius-md); padding: 1.5rem;
|
| 113 |
+
transition: var(--transition);
|
| 114 |
+
}
|
| 115 |
+
.card:hover { border-color: rgba(59,130,246,.3); box-shadow: var(--shadow-glow-blue); }
|
| 116 |
+
.card-header {
|
| 117 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 118 |
+
margin-bottom: 1rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border-color);
|
| 119 |
+
}
|
| 120 |
+
.card-title { font-size: 1rem; font-weight: 700; }
|
| 121 |
+
.card-badge {
|
| 122 |
+
padding: .2rem .6rem; border-radius: 50px; font-size: .65rem;
|
| 123 |
+
font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
|
| 124 |
+
}
|
| 125 |
+
.badge-live { background: rgba(239,68,68,.15); color: var(--accent-red); animation: pulse-badge 2s infinite; }
|
| 126 |
+
.badge-ok { background: rgba(16,185,129,.15); color: var(--accent-green); }
|
| 127 |
+
.badge-warn { background: rgba(245,158,11,.15); color: var(--accent-amber); }
|
| 128 |
+
@keyframes pulse-badge { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
|
| 129 |
+
|
| 130 |
+
/* ββ Stat Cards ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 131 |
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
| 132 |
+
.stat-card {
|
| 133 |
+
background: var(--bg-card); border: 1px solid var(--border-color);
|
| 134 |
+
border-radius: var(--radius-md); padding: 1.25rem 1.5rem;
|
| 135 |
+
position: relative; overflow: hidden; transition: var(--transition);
|
| 136 |
+
}
|
| 137 |
+
.stat-card::before {
|
| 138 |
+
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
| 139 |
+
}
|
| 140 |
+
.stat-card.blue::before { background: var(--gradient-blue); }
|
| 141 |
+
.stat-card.green::before { background: var(--gradient-green); }
|
| 142 |
+
.stat-card.red::before { background: var(--gradient-red); }
|
| 143 |
+
.stat-card.purple::before { background: var(--gradient-purple); }
|
| 144 |
+
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
| 145 |
+
.stat-label { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
|
| 146 |
+
.stat-value { font-size: 2rem; font-weight: 800; margin: .25rem 0; font-family: 'JetBrains Mono', monospace; }
|
| 147 |
+
.stat-value.blue { color: var(--accent-blue); }
|
| 148 |
+
.stat-value.green { color: var(--accent-green); }
|
| 149 |
+
.stat-value.red { color: var(--accent-red); }
|
| 150 |
+
.stat-value.purple { color: var(--accent-purple); }
|
| 151 |
+
.stat-detail { font-size: .75rem; color: var(--text-secondary); }
|
| 152 |
+
|
| 153 |
+
/* ββ Tables ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 154 |
+
.table-wrapper { overflow-x: auto; border-radius: var(--radius-md); border: 1px solid var(--border-color); }
|
| 155 |
+
table { width: 100%; border-collapse: collapse; }
|
| 156 |
+
thead { background: rgba(59,130,246,.06); }
|
| 157 |
+
th {
|
| 158 |
+
padding: .75rem 1rem; text-align: left; font-size: .7rem;
|
| 159 |
+
text-transform: uppercase; letter-spacing: .06em;
|
| 160 |
+
color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border-color);
|
| 161 |
+
}
|
| 162 |
+
td {
|
| 163 |
+
padding: .75rem 1rem; font-size: .85rem; border-bottom: 1px solid rgba(42,54,84,.5);
|
| 164 |
+
color: var(--text-secondary);
|
| 165 |
+
}
|
| 166 |
+
tr:hover td { background: rgba(59,130,246,.04); color: var(--text-primary); }
|
| 167 |
+
.mono { font-family: 'JetBrains Mono', monospace; font-size: .8rem; }
|
| 168 |
+
|
| 169 |
+
/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 170 |
+
.btn {
|
| 171 |
+
display: inline-flex; align-items: center; gap: .5rem;
|
| 172 |
+
padding: .6rem 1.25rem; border-radius: var(--radius-sm);
|
| 173 |
+
font-size: .85rem; font-weight: 600; cursor: pointer;
|
| 174 |
+
border: 1px solid transparent; transition: var(--transition);
|
| 175 |
+
font-family: 'Inter', sans-serif;
|
| 176 |
+
}
|
| 177 |
+
.btn-primary { background: var(--gradient-blue); color: #fff; }
|
| 178 |
+
.btn-primary:hover { box-shadow: 0 4px 20px rgba(59,130,246,.35); transform: translateY(-1px); }
|
| 179 |
+
.btn-success { background: var(--gradient-green); color: #fff; }
|
| 180 |
+
.btn-success:hover { box-shadow: 0 4px 20px rgba(16,185,129,.35); transform: translateY(-1px); }
|
| 181 |
+
.btn-danger { background: var(--gradient-red); color: #fff; }
|
| 182 |
+
.btn-danger:hover { box-shadow: 0 4px 20px rgba(239,68,68,.35); transform: translateY(-1px); }
|
| 183 |
+
.btn-outline {
|
| 184 |
+
background: transparent; border-color: var(--border-color); color: var(--text-secondary);
|
| 185 |
+
}
|
| 186 |
+
.btn-outline:hover { border-color: var(--accent-blue); color: var(--accent-blue); background: rgba(59,130,246,.06); }
|
| 187 |
+
.btn-sm { padding: .4rem .8rem; font-size: .75rem; }
|
| 188 |
+
.btn-icon { width: 36px; height: 36px; padding: 0; display: grid; place-items: center; border-radius: var(--radius-sm); }
|
| 189 |
+
|
| 190 |
+
/* ββ Forms ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 191 |
+
.form-group { margin-bottom: 1.25rem; }
|
| 192 |
+
.form-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: .4rem; }
|
| 193 |
+
.form-input, .form-select {
|
| 194 |
+
width: 100%; padding: .7rem 1rem; background: var(--bg-input);
|
| 195 |
+
border: 1px solid var(--border-color); border-radius: var(--radius-sm);
|
| 196 |
+
color: var(--text-primary); font-size: .875rem; font-family: 'Inter', sans-serif;
|
| 197 |
+
transition: var(--transition);
|
| 198 |
+
}
|
| 199 |
+
.form-input:focus, .form-select:focus {
|
| 200 |
+
outline: none; border-color: var(--accent-blue);
|
| 201 |
+
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
|
| 202 |
+
}
|
| 203 |
+
.form-input::placeholder { color: var(--text-muted); }
|
| 204 |
+
|
| 205 |
+
/* ββ Alerts ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 206 |
+
.alert {
|
| 207 |
+
padding: .85rem 1.25rem; border-radius: var(--radius-sm);
|
| 208 |
+
font-size: .85rem; margin-bottom: 1rem; display: flex; align-items: center; gap: .5rem;
|
| 209 |
+
border: 1px solid;
|
| 210 |
+
}
|
| 211 |
+
.alert-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.25); color: #fca5a5; }
|
| 212 |
+
.alert-success { background: rgba(16,185,129,.1); border-color: rgba(16,185,129,.25); color: #6ee7b7; }
|
| 213 |
+
.alert-info { background: rgba(59,130,246,.1); border-color: rgba(59,130,246,.25); color: #93c5fd; }
|
| 214 |
+
.alert-warning { background: rgba(245,158,11,.1); border-color: rgba(245,158,11,.25); color: #fcd34d; }
|
| 215 |
+
|
| 216 |
+
/* ββ CCTV Camera Feed ββββββββββββββββββββββββββββββββββββββββ */
|
| 217 |
+
.camera-feed {
|
| 218 |
+
position: relative; background: #000; border-radius: var(--radius-md);
|
| 219 |
+
overflow: hidden; border: 2px solid var(--border-color);
|
| 220 |
+
aspect-ratio: 16/9;
|
| 221 |
+
}
|
| 222 |
+
.camera-feed img, .camera-feed video {
|
| 223 |
+
width: 100%; height: 100%; object-fit: cover;
|
| 224 |
+
}
|
| 225 |
+
.camera-overlay {
|
| 226 |
+
position: absolute; top: 0; left: 0; right: 0;
|
| 227 |
+
padding: .5rem .75rem;
|
| 228 |
+
background: linear-gradient(180deg, rgba(0,0,0,.7), transparent);
|
| 229 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 230 |
+
}
|
| 231 |
+
.camera-overlay .rec {
|
| 232 |
+
display: flex; align-items: center; gap: .4rem;
|
| 233 |
+
font-size: .7rem; font-weight: 700; color: var(--accent-red);
|
| 234 |
+
text-transform: uppercase; letter-spacing: .08em;
|
| 235 |
+
}
|
| 236 |
+
.camera-overlay .rec::before {
|
| 237 |
+
content: ''; width: 8px; height: 8px; background: var(--accent-red);
|
| 238 |
+
border-radius: 50%; animation: pulse-badge 1.5s infinite;
|
| 239 |
+
}
|
| 240 |
+
.camera-overlay .timestamp {
|
| 241 |
+
font-family: 'JetBrains Mono', monospace; font-size: .7rem; color: rgba(255,255,255,.7);
|
| 242 |
+
}
|
| 243 |
+
.camera-controls {
|
| 244 |
+
display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap;
|
| 245 |
+
}
|
| 246 |
+
.camera-placeholder {
|
| 247 |
+
display: flex; flex-direction: column; align-items: center;
|
| 248 |
+
justify-content: center; height: 100%; color: var(--text-muted);
|
| 249 |
+
font-size: .85rem; gap: .5rem;
|
| 250 |
+
}
|
| 251 |
+
.camera-placeholder .icon { font-size: 3rem; opacity: .3; }
|
| 252 |
+
|
| 253 |
+
/* ββ OCR Result Panel ββββββββββββββββββββββββββββββββββββββββ */
|
| 254 |
+
.ocr-result {
|
| 255 |
+
border: 1px solid var(--border-color); border-radius: var(--radius-md);
|
| 256 |
+
overflow: hidden; background: var(--bg-card);
|
| 257 |
+
}
|
| 258 |
+
.ocr-result-header {
|
| 259 |
+
padding: 1rem 1.5rem; display: flex; justify-content: space-between;
|
| 260 |
+
align-items: center; border-bottom: 1px solid var(--border-color);
|
| 261 |
+
}
|
| 262 |
+
.ocr-plate {
|
| 263 |
+
font-family: 'JetBrains Mono', monospace; font-size: 1.75rem;
|
| 264 |
+
font-weight: 800; letter-spacing: .05em;
|
| 265 |
+
}
|
| 266 |
+
.ocr-result-body { padding: 1.25rem 1.5rem; }
|
| 267 |
+
.ocr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
| 268 |
+
.ocr-field-label { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .2rem; }
|
| 269 |
+
.ocr-field-value { font-size: .9rem; font-weight: 600; }
|
| 270 |
+
.ocr-image { border-radius: var(--radius-sm); max-width: 100%; margin-top: 1rem; border: 1px solid var(--border-color); }
|
| 271 |
+
|
| 272 |
+
/* ββ Status indicators βββββββββββββββββββββββββββββββββββββββ */
|
| 273 |
+
.status { display: inline-flex; align-items: center; gap: .35rem; font-size: .8rem; font-weight: 600; }
|
| 274 |
+
.status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; }
|
| 275 |
+
.status-registered { color: var(--accent-green); }
|
| 276 |
+
.status-registered::before { background: var(--accent-green); box-shadow: 0 0 8px rgba(16,185,129,.5); }
|
| 277 |
+
.status-unregistered { color: var(--accent-red); }
|
| 278 |
+
.status-unregistered::before { background: var(--accent-red); box-shadow: 0 0 8px rgba(239,68,68,.5); }
|
| 279 |
+
.status-active { color: var(--accent-cyan); }
|
| 280 |
+
.status-active::before { background: var(--accent-cyan); box-shadow: 0 0 8px rgba(6,182,212,.5); }
|
| 281 |
+
|
| 282 |
+
/* ββ Gate Status βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 283 |
+
.gate-status {
|
| 284 |
+
display: flex; align-items: center; gap: 1rem;
|
| 285 |
+
padding: 1rem 1.5rem; border-radius: var(--radius-md);
|
| 286 |
+
font-weight: 700; font-size: 1rem;
|
| 287 |
+
}
|
| 288 |
+
.gate-open { background: rgba(16,185,129,.1); border: 1px solid rgba(16,185,129,.3); color: var(--accent-green); }
|
| 289 |
+
.gate-closed { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); color: var(--accent-red); }
|
| 290 |
+
.gate-icon { font-size: 1.5rem; }
|
| 291 |
+
|
| 292 |
+
/* ββ Auth Pages ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 293 |
+
.auth-wrapper {
|
| 294 |
+
min-height: 100vh; display: grid; place-items: center;
|
| 295 |
+
background: var(--bg-primary);
|
| 296 |
+
background-image: radial-gradient(ellipse at 20% 50%, rgba(59,130,246,.08) 0%, transparent 50%),
|
| 297 |
+
radial-gradient(ellipse at 80% 20%, rgba(139,92,246,.06) 0%, transparent 50%);
|
| 298 |
+
}
|
| 299 |
+
.auth-card {
|
| 300 |
+
width: 100%; max-width: 420px; padding: 2.5rem;
|
| 301 |
+
background: var(--bg-card); border: 1px solid var(--border-color);
|
| 302 |
+
border-radius: var(--radius-xl); box-shadow: var(--shadow-lg);
|
| 303 |
+
}
|
| 304 |
+
.auth-logo { text-align: center; margin-bottom: 2rem; }
|
| 305 |
+
.auth-logo-icon {
|
| 306 |
+
width: 64px; height: 64px; margin: 0 auto .75rem;
|
| 307 |
+
background: var(--gradient-blue); border-radius: var(--radius-md);
|
| 308 |
+
display: grid; place-items: center; font-size: 1.75rem; color: #fff; font-weight: 800;
|
| 309 |
+
}
|
| 310 |
+
.auth-logo h1 { font-size: 1.5rem; font-weight: 800; }
|
| 311 |
+
.auth-logo p { font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
|
| 312 |
+
.auth-footer { text-align: center; margin-top: 1.5rem; font-size: .8rem; color: var(--text-muted); }
|
| 313 |
+
|
| 314 |
+
/* ββ Grid layouts ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 315 |
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
| 316 |
+
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
|
| 317 |
+
.content-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; }
|
| 318 |
+
|
| 319 |
+
/* ββ Modal ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 320 |
+
.modal-overlay {
|
| 321 |
+
position: fixed; inset: 0; background: rgba(0,0,0,.65);
|
| 322 |
+
backdrop-filter: blur(4px); display: none; place-items: center; z-index: 1000;
|
| 323 |
+
}
|
| 324 |
+
.modal-overlay.active { display: grid; }
|
| 325 |
+
.modal {
|
| 326 |
+
background: var(--bg-card); border: 1px solid var(--border-color);
|
| 327 |
+
border-radius: var(--radius-lg); padding: 2rem; width: 90%; max-width: 500px;
|
| 328 |
+
box-shadow: var(--shadow-lg);
|
| 329 |
+
}
|
| 330 |
+
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; }
|
| 331 |
+
.modal-title { font-size: 1.1rem; font-weight: 700; }
|
| 332 |
+
.modal-close { background: none; border: none; color: var(--text-muted); font-size: 1.25rem; cursor: pointer; }
|
| 333 |
+
.modal-close:hover { color: var(--text-primary); }
|
| 334 |
+
.modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
| 335 |
+
|
| 336 |
+
/* ββ Loading βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 337 |
+
.spinner {
|
| 338 |
+
width: 32px; height: 32px; border: 3px solid var(--border-color);
|
| 339 |
+
border-top-color: var(--accent-blue); border-radius: 50%;
|
| 340 |
+
animation: spin .8s linear infinite;
|
| 341 |
+
}
|
| 342 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 343 |
+
.loading-overlay {
|
| 344 |
+
position: absolute; inset: 0; background: rgba(10,14,23,.8);
|
| 345 |
+
display: none; place-items: center; z-index: 10; border-radius: var(--radius-md);
|
| 346 |
+
}
|
| 347 |
+
.loading-overlay.active { display: grid; }
|
| 348 |
+
|
| 349 |
+
/* ββ Animations ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 350 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
| 351 |
+
.fade-in { animation: fadeIn .4s ease-out; }
|
| 352 |
+
@keyframes slideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
|
| 353 |
+
|
| 354 |
+
/* ββ Responsive ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 355 |
+
@media (max-width: 1024px) {
|
| 356 |
+
.sidebar { width: 220px; }
|
| 357 |
+
.main-content { margin-left: 220px; }
|
| 358 |
+
.grid-3 { grid-template-columns: 1fr 1fr; }
|
| 359 |
+
}
|
| 360 |
+
@media (max-width: 768px) {
|
| 361 |
+
.sidebar { display: none; }
|
| 362 |
+
.main-content { margin-left: 0; padding: 1rem; }
|
| 363 |
+
.grid-2, .grid-3, .content-grid { grid-template-columns: 1fr; }
|
| 364 |
+
.stats-grid { grid-template-columns: 1fr 1fr; }
|
| 365 |
+
.ocr-grid { grid-template-columns: 1fr; }
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/* ββ Utility βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 369 |
+
.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; } .mt-3 { margin-top: 1.5rem; }
|
| 370 |
+
.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
|
| 371 |
+
.text-center { text-align: center; }
|
| 372 |
+
.text-right { text-align: right; }
|
| 373 |
+
.flex { display: flex; } .flex-between { display: flex; justify-content: space-between; align-items: center; }
|
| 374 |
+
.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; }
|
| 375 |
+
.hidden { display: none !important; }
|
| 376 |
+
.w-full { width: 100%; }
|
| 377 |
+
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 378 |
+
|
| 379 |
+
/* ββ Premium UI Overhaul (Uber-Professional) ββββββββββββββββββ */
|
| 380 |
+
.glass-card {
|
| 381 |
+
background: rgba(26, 34, 54, 0.75);
|
| 382 |
+
backdrop-filter: blur(12px);
|
| 383 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 384 |
+
border-radius: 16px;
|
| 385 |
+
padding: 1.5rem;
|
| 386 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 387 |
+
}
|
| 388 |
+
.pulse-badge {
|
| 389 |
+
display: flex; align-items: center; gap: 8px;
|
| 390 |
+
background: rgba(239, 68, 68, 0.12); color: #f87171;
|
| 391 |
+
padding: 6px 12px; border-radius: 20px; font-size: 0.7rem; font-weight: 700;
|
| 392 |
+
text-transform: uppercase; letter-spacing: 1px;
|
| 393 |
+
}
|
| 394 |
+
.pulse-dot {
|
| 395 |
+
width: 8px; height: 8px; background: #ef4444; border-radius: 50%;
|
| 396 |
+
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 397 |
+
}
|
| 398 |
+
@keyframes pulse-ring {
|
| 399 |
+
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
| 400 |
+
70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
|
| 401 |
+
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
| 402 |
+
}
|
| 403 |
+
#gateOverlay {
|
| 404 |
+
position: fixed; inset: 0; background: rgba(10, 14, 23, 0.96);
|
| 405 |
+
z-index: 10000; display: none; flex-direction: column;
|
| 406 |
+
align-items: center; justify-content: center; backdrop-filter: blur(20px);
|
| 407 |
+
}
|
| 408 |
+
.gate-timer-circle {
|
| 409 |
+
width: 220px; height: 220px; border: 10px solid #10b981;
|
| 410 |
+
border-radius: 50%; display: grid; place-items: center;
|
| 411 |
+
font-size: 6.5rem; font-weight: 900; color: #10b981;
|
| 412 |
+
box-shadow: 0 0 60px rgba(16, 185, 129, 0.3); margin-bottom: 2rem;
|
| 413 |
+
}
|
| 414 |
+
.status-pill { padding: 4px 12px; border-radius: 6px; font-size: 0.7rem; font-weight: 800; text-transform: uppercase; }
|
| 415 |
+
.status-pill.guest { background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); }
|
| 416 |
+
.status-pill.reg { background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
|
| 417 |
+
.duration-timer { font-family: 'JetBrains Mono', monospace; font-weight: 700; color: #3b82f6; background: rgba(59, 130, 246, 0.1); padding: 2px 6px; border-radius: 4px; }
|
| 418 |
+
.plate-display { font-family: 'JetBrains Mono', monospace; font-size: 2.5rem; font-weight: 800; letter-spacing: 2px; color: white; }
|
templates/admin/dashboard.html
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Admin Dashboard β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="app-layout">
|
| 5 |
+
{% with role='admin', active='dashboard' %}
|
| 6 |
+
{% include "partials/sidebar.html" %}
|
| 7 |
+
{% endwith %}
|
| 8 |
+
|
| 9 |
+
<main class="main-content">
|
| 10 |
+
<div class="page-header fade-in flex-between">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="page-title">Admin Dashboard</h1>
|
| 13 |
+
<p class="page-subtitle">Parking facility management & analytics</p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="pulse-badge">
|
| 16 |
+
<div class="pulse-dot"></div>
|
| 17 |
+
System Live
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="stats-grid fade-in" id="statsGrid">
|
| 22 |
+
<div class="stat-card blue">
|
| 23 |
+
<div class="stat-label">Total Capacity</div>
|
| 24 |
+
<div class="stat-value blue" id="statCapacity">β</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="stat-card green">
|
| 27 |
+
<div class="stat-label">Currently Inside</div>
|
| 28 |
+
<div class="stat-value green" id="statInside">β</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="stat-card purple">
|
| 31 |
+
<div class="stat-label">Available Spots</div>
|
| 32 |
+
<div class="stat-value purple" id="statAvailable">β</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="stat-card red">
|
| 35 |
+
<div class="stat-label">Occupancy Rate</div>
|
| 36 |
+
<div class="stat-value red" id="statOccRate">β</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="content-grid" style="grid-template-columns: 1fr;">
|
| 41 |
+
<!-- Inside Cars -->
|
| 42 |
+
<div class="card fade-in">
|
| 43 |
+
<div class="card-header">
|
| 44 |
+
<span class="card-title">π Vehicles Inside</span>
|
| 45 |
+
<span class="card-badge badge-live">LIVE</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
|
| 48 |
+
<table>
|
| 49 |
+
<thead>
|
| 50 |
+
<tr>
|
| 51 |
+
<th>Plate</th>
|
| 52 |
+
<th>Registration</th>
|
| 53 |
+
<th>Entry</th>
|
| 54 |
+
<th>Duration</th>
|
| 55 |
+
<th>Est. Fee</th>
|
| 56 |
+
<th>Status</th>
|
| 57 |
+
<th>Payment</th>
|
| 58 |
+
</tr>
|
| 59 |
+
</thead>
|
| 60 |
+
<tbody id="insideBody">
|
| 61 |
+
<tr><td colspan="7" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading live data...</td></tr></tbody>
|
| 62 |
+
</table>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- Quick Admin Actions -->
|
| 68 |
+
<div class="card fade-in">
|
| 69 |
+
<div class="card-header">
|
| 70 |
+
<span class="card-title">β‘ Administration</span>
|
| 71 |
+
</div>
|
| 72 |
+
<div style="display:flex;gap:.75rem;flex-wrap:wrap;">
|
| 73 |
+
<a href="{{ url_for('admin.garage') }}" class="btn btn-outline" style="background:var(--accent-blue);color:white;border:none;">
|
| 74 |
+
<span class="icon">ποΈ</span> Garage Status
|
| 75 |
+
</a>
|
| 76 |
+
<a href="{{ url_for('admin.users') }}" class="btn btn-outline">
|
| 77 |
+
<span class="icon">π₯</span> Manage Users
|
| 78 |
+
</a>
|
| 79 |
+
<a href="{{ url_for('admin.vehicles') }}" class="btn btn-outline">
|
| 80 |
+
<span class="icon">π</span> Manage Vehicles
|
| 81 |
+
</a>
|
| 82 |
+
<a href="{{ url_for('admin.history') }}" class="btn btn-outline">
|
| 83 |
+
<span class="icon">π</span> Parking History
|
| 84 |
+
</a>
|
| 85 |
+
<button class="btn btn-danger" onclick="runMaintenance()"><span>π§Ή</span> Refresh Storage (Photos)</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</main>
|
| 89 |
+
</div>
|
| 90 |
+
{% endblock %}
|
| 91 |
+
|
| 92 |
+
{% block scripts %}
|
| 93 |
+
<script>
|
| 94 |
+
function updateStats(occ) {
|
| 95 |
+
if (!occ) return;
|
| 96 |
+
const g = occ.garage || occ;
|
| 97 |
+
const cap = g.total_capacity || g.capacity || 0;
|
| 98 |
+
const inside = g.inside || g.occupied || g.current_count || 0;
|
| 99 |
+
document.getElementById('statCapacity').textContent = cap || 'β';
|
| 100 |
+
document.getElementById('statInside').textContent = inside;
|
| 101 |
+
document.getElementById('statAvailable').textContent = cap ? cap - inside : 'β';
|
| 102 |
+
document.getElementById('statOccRate').textContent = cap ? Math.round((inside/cap)*100) + '%' : 'β';
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function formatDuration(mins) {
|
| 106 |
+
if (mins == null) return 'β';
|
| 107 |
+
const h = Math.floor(mins / 60);
|
| 108 |
+
const m = Math.floor(mins % 60);
|
| 109 |
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function renderInside(data) {
|
| 113 |
+
const body = document.getElementById('insideBody');
|
| 114 |
+
// Backend returns { items: [...] } where each item has { session, vehicle, pricing, payment_status }
|
| 115 |
+
let items = Array.isArray(data) ? data : (data.items || data.data || data.cars || []);
|
| 116 |
+
if (!items.length) { body.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">No vehicles inside</td></tr>'; return; }
|
| 117 |
+
body.innerHTML = items.map(item => {
|
| 118 |
+
const s = item.session || item;
|
| 119 |
+
const v = item.vehicle || {};
|
| 120 |
+
const p = item.pricing || {};
|
| 121 |
+
const ps = item.payment_status || {};
|
| 122 |
+
|
| 123 |
+
const plate = s.plate_arabic || s.plate_english || s.plate_key || v.plate_letters_ar || 'β';
|
| 124 |
+
const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : (s.entry_time ? new Date(s.entry_time).toLocaleString() : 'β');
|
| 125 |
+
const duration = formatDuration(p.duration_minutes);
|
| 126 |
+
const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β';
|
| 127 |
+
const status = s.status || 'entered';
|
| 128 |
+
const statusClass = status === 'paid' ? 'status-registered' : 'status-active';
|
| 129 |
+
const statusLabel = status === 'paid' ? 'β
Paid' : status === 'entered' ? 'π΅ Inside' : status;
|
| 130 |
+
|
| 131 |
+
// Payment method badge
|
| 132 |
+
const notes = (s.notes || '').toLowerCase();
|
| 133 |
+
const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
|
| 134 |
+
const isGuest = !v.id && !v.plate_letters_ar;
|
| 135 |
+
|
| 136 |
+
let registration = isGuest
|
| 137 |
+
? '<span style="color:var(--accent-red);font-size:0.8rem;font-weight:bold;">Unregistered</span>'
|
| 138 |
+
: '<span style="color:var(--accent-green);font-size:0.8rem;font-weight:bold;">Registered</span>';
|
| 139 |
+
|
| 140 |
+
let isCash = isGuest || hasCashIntent;
|
| 141 |
+
let isPaidVisa = s.payment_confirmed_at != null && !isCash;
|
| 142 |
+
|
| 143 |
+
let payBadge = 'β';
|
| 144 |
+
if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">π³ Visa</span>';
|
| 145 |
+
else if (isCash) payBadge = '<span style="color:var(--accent-green);">π΅ Cash</span>';
|
| 146 |
+
|
| 147 |
+
return `<tr>
|
| 148 |
+
<td class="mono">${plate}</td>
|
| 149 |
+
<td>${registration}</td>
|
| 150 |
+
<td>${entry}</td>
|
| 151 |
+
<td>${duration}</td><td>${fee}</td>
|
| 152 |
+
<td><span class="status ${statusClass}">${statusLabel}</span></td>
|
| 153 |
+
<td>${payBadge}</td>
|
| 154 |
+
</tr>`;
|
| 155 |
+
}).join('');
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async function refreshData() {
|
| 161 |
+
try {
|
| 162 |
+
const [oR, iR] = await Promise.all([
|
| 163 |
+
fetch('/admin/api/occupancy'), fetch('/admin/api/inside-cars')
|
| 164 |
+
]);
|
| 165 |
+
updateStats(await oR.json()); renderInside(await iR.json());
|
| 166 |
+
} catch(e) { console.error(e); }
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function runMaintenance() {
|
| 170 |
+
if (!confirm('Clear database photo storage? (Profiles and tables will not be affected)')) return;
|
| 171 |
+
try {
|
| 172 |
+
const r = await fetch('/admin/api/maintenance', {method:'POST'});
|
| 173 |
+
const d = await r.json();
|
| 174 |
+
alert(d.message || d.error || 'Done');
|
| 175 |
+
} catch(e) { alert('Error: ' + e); }
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
refreshData();
|
| 179 |
+
setInterval(refreshData, 2000); // 2 seconds for high-tier real-time feel
|
| 180 |
+
</script>
|
| 181 |
+
{% endblock %}
|
templates/admin/garage.html
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Garage Status β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="app-layout">
|
| 5 |
+
{% with role='admin', active='garage' %}
|
| 6 |
+
{% include "partials/sidebar.html" %}
|
| 7 |
+
{% endwith %}
|
| 8 |
+
|
| 9 |
+
<main class="main-content">
|
| 10 |
+
<div class="page-header fade-in flex-between">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="page-title">Garage Status</h1>
|
| 13 |
+
<p class="page-subtitle">Live view of all parking operations</p>
|
| 14 |
+
</div>
|
| 15 |
+
<button class="btn btn-outline" onclick="refreshAll()"><span>π</span> Refresh</button>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="stats-grid fade-in">
|
| 19 |
+
<div class="stat-card green">
|
| 20 |
+
<div class="stat-label">Entered Today</div>
|
| 21 |
+
<div class="stat-value green" id="sEntered">β</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="stat-card blue">
|
| 24 |
+
<div class="stat-label">Currently Inside</div>
|
| 25 |
+
<div class="stat-value blue" id="sInside">β</div>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="stat-card purple">
|
| 28 |
+
<div class="stat-label">Left Today</div>
|
| 29 |
+
<div class="stat-value purple" id="sLeft">β</div>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="stat-card red">
|
| 32 |
+
<div class="stat-label">Leaving Soon</div>
|
| 33 |
+
<div class="stat-value red" id="sLeaving">β</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<!-- Tabs -->
|
| 38 |
+
<div class="card fade-in">
|
| 39 |
+
<div style="display:flex;gap:.5rem;margin-bottom:1rem;border-bottom:1px solid var(--border-color);padding-bottom:.75rem;">
|
| 40 |
+
<button class="btn btn-sm btn-primary" onclick="showTab('inside')" id="tabInside">Inside</button>
|
| 41 |
+
<button class="btn btn-sm btn-outline" onclick="showTab('entered')" id="tabEntered">Entered</button>
|
| 42 |
+
<button class="btn btn-sm btn-outline" onclick="showTab('left')" id="tabLeft">Left</button>
|
| 43 |
+
<button class="btn btn-sm btn-outline" onclick="showTab('leaving')" id="tabLeaving">Leaving Soon</button>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
|
| 46 |
+
<table>
|
| 47 |
+
<thead>
|
| 48 |
+
<tr>
|
| 49 |
+
<th>Plate</th>
|
| 50 |
+
<th>Registration</th>
|
| 51 |
+
<th>Entry Time</th>
|
| 52 |
+
<th>Exit Time</th>
|
| 53 |
+
<th>Duration</th>
|
| 54 |
+
<th>Fee</th>
|
| 55 |
+
<th>Status</th>
|
| 56 |
+
<th>Payment</th>
|
| 57 |
+
</tr>
|
| 58 |
+
</thead>
|
| 59 |
+
<tbody id="tableBody">
|
| 60 |
+
<tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading data...</td></tr></tbody>
|
| 61 |
+
</table>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</main>
|
| 65 |
+
</div>
|
| 66 |
+
{% endblock %}
|
| 67 |
+
|
| 68 |
+
{% block scripts %}
|
| 69 |
+
<script>
|
| 70 |
+
let cacheData = { inside:[], entered:[], left:[], leaving:[] };
|
| 71 |
+
let currentTab = 'inside';
|
| 72 |
+
|
| 73 |
+
function showTab(tab) {
|
| 74 |
+
currentTab = tab;
|
| 75 |
+
['inside','entered','left','leaving'].forEach(t => {
|
| 76 |
+
document.getElementById('tab'+t.charAt(0).toUpperCase()+t.slice(1)).className = t===tab ? 'btn btn-sm btn-primary' : 'btn btn-sm btn-outline';
|
| 77 |
+
});
|
| 78 |
+
renderTable(cacheData[tab]);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function extractItems(data) {
|
| 82 |
+
if (!data) return [];
|
| 83 |
+
if (Array.isArray(data)) return data;
|
| 84 |
+
return data.items || data.history || data.data || data.cars || data.sessions || [];
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function normalizePlate(p) {
|
| 88 |
+
if (!p) return '';
|
| 89 |
+
const map = {'Ω ':'0','Ω‘':'1','Ω’':'2','Ω£':'3','Ω€':'4','Ω₯':'5','Ω¦':'6','Ω§':'7','Ω¨':'8','Ω©':'9'};
|
| 90 |
+
return String(p).replace(/[Ω -Ω©]/g, d => map[d]).replace(/[\s\-\|]/g, '').trim();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function formatDuration(mins) {
|
| 94 |
+
if (mins == null) return 'β';
|
| 95 |
+
const h = Math.floor(mins / 60);
|
| 96 |
+
const m = Math.floor(mins % 60);
|
| 97 |
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function renderTable(items) {
|
| 101 |
+
const body = document.getElementById('tableBody');
|
| 102 |
+
if (!items || !items.length) {
|
| 103 |
+
body.innerHTML = '<tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">No records</td></tr>';
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
body.innerHTML = items.map(item => {
|
| 107 |
+
const s = item.session || item;
|
| 108 |
+
const v = item.vehicle || {};
|
| 109 |
+
const p = item.pricing || {};
|
| 110 |
+
const ps = item.payment_status || {};
|
| 111 |
+
|
| 112 |
+
const plate = s.plate_arabic || s.plate_english || s.plate_key ||
|
| 113 |
+
((v.plate_letters_ar||'') + (v.plate_numbers_ar||'')) || 'β';
|
| 114 |
+
const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : 'β';
|
| 115 |
+
const exit = s.check_out_at ? new Date(s.check_out_at).toLocaleString() : 'β';
|
| 116 |
+
const duration = formatDuration(p.duration_minutes);
|
| 117 |
+
const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β';
|
| 118 |
+
|
| 119 |
+
const status = s.status || (s.check_out_at ? 'exited' : 'entered');
|
| 120 |
+
let stLabel, stClass;
|
| 121 |
+
if (status === 'exited' || s.check_out_at) { stLabel = 'Left'; stClass = 'status-unregistered'; }
|
| 122 |
+
else if (status === 'paid') { stLabel = 'Paid'; stClass = 'status-registered'; }
|
| 123 |
+
else { stLabel = 'Inside'; stClass = 'status-active'; }
|
| 124 |
+
|
| 125 |
+
// Payment badge
|
| 126 |
+
const notes = (s.notes || '').toLowerCase();
|
| 127 |
+
const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
|
| 128 |
+
const isGuest = !v.id && !v.plate_letters_ar;
|
| 129 |
+
|
| 130 |
+
let registration = isGuest
|
| 131 |
+
? '<span style="color:var(--accent-red);font-size:0.8rem;font-weight:bold;">Unregistered</span>'
|
| 132 |
+
: '<span style="color:var(--accent-green);font-size:0.8rem;font-weight:bold;">Registered</span>';
|
| 133 |
+
|
| 134 |
+
let isCash = isGuest || hasCashIntent;
|
| 135 |
+
let isPaidVisa = s.payment_confirmed_at != null && !isCash;
|
| 136 |
+
|
| 137 |
+
let payBadge = 'β';
|
| 138 |
+
if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">π³ Visa</span>';
|
| 139 |
+
else if (isCash) payBadge = '<span style="color:var(--accent-green);">π΅ Cash</span>';
|
| 140 |
+
|
| 141 |
+
return `<tr>
|
| 142 |
+
<td class="mono">${plate}</td>
|
| 143 |
+
<td>${registration}</td>
|
| 144 |
+
<td>${entry}</td><td>${exit}</td>
|
| 145 |
+
<td>${duration}</td><td>${fee}</td>
|
| 146 |
+
<td><span class="status ${stClass}">${stLabel}</span></td>
|
| 147 |
+
<td>${payBadge}</td>
|
| 148 |
+
</tr>`;
|
| 149 |
+
}).join('');
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async function refreshAll() {
|
| 153 |
+
try {
|
| 154 |
+
const [iR, eR, lR, lvR] = await Promise.all([
|
| 155 |
+
fetch('/admin/api/inside-cars'), fetch('/admin/api/entered-cars'),
|
| 156 |
+
fetch('/admin/api/left-cars'), fetch('/admin/api/leaving-soon')
|
| 157 |
+
]);
|
| 158 |
+
const rawInside = extractItems(await iR.json());
|
| 159 |
+
const rawEntered = extractItems(await eR.json());
|
| 160 |
+
const rawLeft = extractItems(await lR.json());
|
| 161 |
+
const rawLeaving = extractItems(await lvR.json());
|
| 162 |
+
|
| 163 |
+
const leftPlates = new Set(rawLeft.map(item => {
|
| 164 |
+
const s = item.session || item;
|
| 165 |
+
return normalizePlate(s.plate_arabic || s.plate_english || s.plate_key);
|
| 166 |
+
}).filter(Boolean));
|
| 167 |
+
|
| 168 |
+
cacheData.inside = rawInside.filter(item => {
|
| 169 |
+
const s = item.session || item;
|
| 170 |
+
const plate = normalizePlate(s.plate_arabic || s.plate_english || s.plate_key);
|
| 171 |
+
return !leftPlates.has(plate);
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
cacheData.entered = rawEntered;
|
| 175 |
+
cacheData.left = rawLeft;
|
| 176 |
+
cacheData.leaving = rawLeaving;
|
| 177 |
+
|
| 178 |
+
document.getElementById('sInside').textContent = cacheData.inside.length;
|
| 179 |
+
document.getElementById('sEntered').textContent = cacheData.entered.length;
|
| 180 |
+
document.getElementById('sLeft').textContent = cacheData.left.length;
|
| 181 |
+
document.getElementById('sLeaving').textContent = cacheData.leaving.length;
|
| 182 |
+
renderTable(cacheData[currentTab]);
|
| 183 |
+
} catch(e) { console.error(e); }
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
refreshAll();
|
| 187 |
+
setInterval(refreshAll, 2000); // 2 seconds for high-tier real-time feel
|
| 188 |
+
</script>
|
| 189 |
+
{% endblock %}
|
templates/admin/history.html
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Parking History β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="app-layout">
|
| 5 |
+
{% with role='admin', active='history' %}
|
| 6 |
+
{% include "partials/sidebar.html" %}
|
| 7 |
+
{% endwith %}
|
| 8 |
+
|
| 9 |
+
<main class="main-content">
|
| 10 |
+
<div class="page-header fade-in flex-between">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="page-title">Parking History</h1>
|
| 13 |
+
<p class="page-subtitle">Complete ledger of all parking sessions</p>
|
| 14 |
+
</div>
|
| 15 |
+
<button class="btn btn-outline" onclick="loadHistory()"><span>π</span> Refresh</button>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="card fade-in">
|
| 19 |
+
<div class="table-wrapper" style="max-height:600px;overflow-y:auto;">
|
| 20 |
+
<table>
|
| 21 |
+
<thead>
|
| 22 |
+
<tr>
|
| 23 |
+
<th>Plate</th>
|
| 24 |
+
<th>Owner / User</th>
|
| 25 |
+
<th>Entry Time</th>
|
| 26 |
+
<th>Exit Time</th>
|
| 27 |
+
<th>Duration</th>
|
| 28 |
+
<th>Fee</th>
|
| 29 |
+
<th>Payment</th>
|
| 30 |
+
<th>Status</th>
|
| 31 |
+
</tr>
|
| 32 |
+
</thead>
|
| 33 |
+
<tbody id="histBody">
|
| 34 |
+
<tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading history...</td></tr>
|
| 35 |
+
</tbody>
|
| 36 |
+
</table>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</main>
|
| 40 |
+
</div>
|
| 41 |
+
{% endblock %}
|
| 42 |
+
|
| 43 |
+
{% block scripts %}
|
| 44 |
+
<script>
|
| 45 |
+
function formatDuration(mins) {
|
| 46 |
+
if (mins == null) return 'β';
|
| 47 |
+
const h = Math.floor(mins / 60);
|
| 48 |
+
const m = Math.floor(mins % 60);
|
| 49 |
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
async function loadHistory() {
|
| 53 |
+
try {
|
| 54 |
+
const r = await fetch('/admin/api/history');
|
| 55 |
+
const data = await r.json();
|
| 56 |
+
// Backend returns { history: [...] } where each item has { session, vehicle, pricing, payment_status, events }
|
| 57 |
+
let items = Array.isArray(data) ? data : (data.history || data.items || data.data || data.sessions || []);
|
| 58 |
+
const body = document.getElementById('histBody');
|
| 59 |
+
if (!items.length) {
|
| 60 |
+
body.innerHTML = '<tr><td colspan="9" class="text-center" style="padding:2rem;color:var(--text-muted);">No history records</td></tr>';
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
body.innerHTML = items.map(item => {
|
| 64 |
+
const s = item.session || item;
|
| 65 |
+
const v = item.vehicle || {};
|
| 66 |
+
const p = item.pricing || {};
|
| 67 |
+
const ps = item.payment_status || {};
|
| 68 |
+
|
| 69 |
+
const plate = s.plate_arabic || s.plate_english || s.plate_key ||
|
| 70 |
+
((v.plate_letters_ar||'') + (v.plate_numbers_ar||'')) || 'β';
|
| 71 |
+
|
| 72 |
+
// Attempt to extract username
|
| 73 |
+
let ownerName = 'β';
|
| 74 |
+
if (v.owner) {
|
| 75 |
+
ownerName = v.owner.username || v.owner.full_name || v.owner.first_name || 'β';
|
| 76 |
+
} else if (v.owner_id) {
|
| 77 |
+
ownerName = 'Registered';
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const isGuest = !v.id && !v.plate_letters_ar;
|
| 81 |
+
if (isGuest) {
|
| 82 |
+
ownerName = '<span class="status status-unregistered" style="font-size:0.7rem;padding:2px 6px;">Unregistered</span>';
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : 'β';
|
| 86 |
+
const exit = s.check_out_at ? new Date(s.check_out_at).toLocaleString() : 'β';
|
| 87 |
+
const duration = formatDuration(p.duration_minutes);
|
| 88 |
+
const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β';
|
| 89 |
+
|
| 90 |
+
// Payment status
|
| 91 |
+
const notes = (s.notes || '').toLowerCase();
|
| 92 |
+
let payBadge = 'β³ Pending';
|
| 93 |
+
if (s.payment_confirmed_at) payBadge = 'π³ Visa Paid';
|
| 94 |
+
else if (s.status === 'paid' || ps.is_paid) payBadge = 'β
Paid';
|
| 95 |
+
else if (notes.includes('intent_cash') || notes.includes('cash_confirmed')) payBadge = 'π΅ Cash';
|
| 96 |
+
|
| 97 |
+
// Status
|
| 98 |
+
const status = s.status || (s.check_out_at ? 'exited' : 'entered');
|
| 99 |
+
let stLabel, stClass;
|
| 100 |
+
if (status === 'exited' || s.check_out_at) { stLabel = 'Completed'; stClass = 'status-registered'; }
|
| 101 |
+
else if (status === 'paid') { stLabel = 'Paid (Inside)'; stClass = 'status-registered'; }
|
| 102 |
+
else { stLabel = 'Active'; stClass = 'status-active'; }
|
| 103 |
+
|
| 104 |
+
return `<tr>
|
| 105 |
+
<td class="mono">${plate}</td>
|
| 106 |
+
<td>${ownerName}</td>
|
| 107 |
+
<td>${entry}</td><td>${exit}</td>
|
| 108 |
+
<td>${duration}</td><td>${fee}</td><td>${payBadge}</td>
|
| 109 |
+
<td><span class="status ${stClass}">${stLabel}</span></td>
|
| 110 |
+
</tr>`;
|
| 111 |
+
}).join('');
|
| 112 |
+
} catch(e) { console.error(e); }
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
loadHistory();
|
| 116 |
+
</script>
|
| 117 |
+
{% endblock %}
|
templates/admin/users.html
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}User Management β Ain El Aql{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block body %}
|
| 5 |
+
<div class="app-layout">
|
| 6 |
+
{% with role='admin', active='users' %}
|
| 7 |
+
{% include "partials/sidebar.html" %}
|
| 8 |
+
{% endwith %}
|
| 9 |
+
|
| 10 |
+
<main class="main-content">
|
| 11 |
+
<div class="page-header fade-in">
|
| 12 |
+
<h1 class="page-title">User Management</h1>
|
| 13 |
+
<p class="page-subtitle">View and manage all registered users.</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="card fade-in">
|
| 17 |
+
<div class="card-header">
|
| 18 |
+
<span class="card-title">π₯ All Users</span>
|
| 19 |
+
<button class="btn btn-outline" onclick="loadUsers()">π Refresh</button>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="table-wrapper">
|
| 22 |
+
<table>
|
| 23 |
+
<thead>
|
| 24 |
+
<tr>
|
| 25 |
+
<th>Name</th>
|
| 26 |
+
<th>Username</th>
|
| 27 |
+
<th>Role</th>
|
| 28 |
+
<th>Phone</th>
|
| 29 |
+
<th>Email</th>
|
| 30 |
+
</tr>
|
| 31 |
+
</thead>
|
| 32 |
+
<tbody id="usersBody">
|
| 33 |
+
<tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading users...</td></tr>
|
| 34 |
+
</tbody>
|
| 35 |
+
</table>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</main>
|
| 39 |
+
</div>
|
| 40 |
+
{% endblock %}
|
| 41 |
+
|
| 42 |
+
{% block scripts %}
|
| 43 |
+
<script>
|
| 44 |
+
async function loadUsers() {
|
| 45 |
+
const body = document.getElementById('usersBody');
|
| 46 |
+
try {
|
| 47 |
+
const res = await fetch('/admin/api/users');
|
| 48 |
+
const data = await res.json();
|
| 49 |
+
const users = Array.isArray(data) ? data : (data.users || data.data || []);
|
| 50 |
+
|
| 51 |
+
if (!users.length) {
|
| 52 |
+
body.innerHTML = '<tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--text-muted);">No users found.</td></tr>';
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
body.innerHTML = users.map(u => {
|
| 57 |
+
const name = u.full_name || (u.first_name + ' ' + (u.last_name || '')) || 'β';
|
| 58 |
+
const role = u.role || 'user';
|
| 59 |
+
const roleClass = role === 'admin' ? 'status-registered' : (role === 'security' ? 'status-active' : 'status-unregistered');
|
| 60 |
+
return `<tr>
|
| 61 |
+
<td style="font-weight:bold;color:white;">${name}</td>
|
| 62 |
+
<td class="mono">${u.username || 'β'}</td>
|
| 63 |
+
<td><span class="status ${roleClass}">${role.toUpperCase()}</span></td>
|
| 64 |
+
<td>${u.phone_number || 'β'}</td>
|
| 65 |
+
<td>${u.email || 'β'}</td>
|
| 66 |
+
</tr>`;
|
| 67 |
+
}).join('');
|
| 68 |
+
} catch (err) {
|
| 69 |
+
console.error(err);
|
| 70 |
+
body.innerHTML = '<tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--error);">Failed to load users.</td></tr>';
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
loadUsers();
|
| 75 |
+
</script>
|
| 76 |
+
{% endblock %}
|
templates/admin/vehicles.html
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Vehicles β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="app-layout">
|
| 5 |
+
{% with role='admin', active='vehicles' %}
|
| 6 |
+
{% include "partials/sidebar.html" %}
|
| 7 |
+
{% endwith %}
|
| 8 |
+
|
| 9 |
+
<main class="main-content">
|
| 10 |
+
<div class="page-header fade-in flex-between">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="page-title">Vehicle Management</h1>
|
| 13 |
+
<p class="page-subtitle">All vehicles currently in the system</p>
|
| 14 |
+
</div>
|
| 15 |
+
<button class="btn btn-outline" onclick="loadVehicles()"><span>π</span> Refresh</button>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="stats-grid fade-in">
|
| 19 |
+
<div class="stat-card blue">
|
| 20 |
+
<div class="stat-label">Total Vehicles</div>
|
| 21 |
+
<div class="stat-value blue" id="statTotal">β</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="stat-card green">
|
| 24 |
+
<div class="stat-label">Currently Inside</div>
|
| 25 |
+
<div class="stat-value green" id="statActive">β</div>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="stat-card purple">
|
| 28 |
+
<div class="stat-label">Registered</div>
|
| 29 |
+
<div class="stat-value purple" id="statRegistered">β</div>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="stat-card red">
|
| 32 |
+
<div class="stat-label">Unregistered</div>
|
| 33 |
+
<div class="stat-value red" id="statUnregistered">β</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="card fade-in">
|
| 38 |
+
<div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
|
| 39 |
+
<table>
|
| 40 |
+
<thead>
|
| 41 |
+
<tr>
|
| 42 |
+
<th>Plate (AR)</th>
|
| 43 |
+
<th>Owner / User</th>
|
| 44 |
+
<th>Status</th>
|
| 45 |
+
<th>Duration</th>
|
| 46 |
+
<th>Fee</th>
|
| 47 |
+
<th>Payment</th>
|
| 48 |
+
</tr>
|
| 49 |
+
</thead>
|
| 50 |
+
<tbody id="vehBody">
|
| 51 |
+
<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading...</td></tr></tbody>
|
| 52 |
+
</table>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</main>
|
| 56 |
+
</div>
|
| 57 |
+
{% endblock %}
|
| 58 |
+
|
| 59 |
+
{% block scripts %}
|
| 60 |
+
<script>
|
| 61 |
+
async function loadVehicles() {
|
| 62 |
+
try {
|
| 63 |
+
// Use inside-cars feed + history to show all known vehicles
|
| 64 |
+
const [insideR, histR] = await Promise.all([
|
| 65 |
+
fetch('/admin/api/inside-cars'),
|
| 66 |
+
fetch('/admin/api/history')
|
| 67 |
+
]);
|
| 68 |
+
|
| 69 |
+
const insideData = await insideR.json();
|
| 70 |
+
const histData = await histR.json();
|
| 71 |
+
|
| 72 |
+
const insideItems = insideData.items || insideData.data || [];
|
| 73 |
+
const histItems = histData.history || histData.items || histData.data || [];
|
| 74 |
+
|
| 75 |
+
// Deduplicate by session id
|
| 76 |
+
const seen = new Set();
|
| 77 |
+
const allItems = [];
|
| 78 |
+
|
| 79 |
+
// Inside cars first (active)
|
| 80 |
+
for (const item of insideItems) {
|
| 81 |
+
const s = item.session || item;
|
| 82 |
+
const sid = s.id || '';
|
| 83 |
+
if (sid && !seen.has(sid)) { seen.add(sid); allItems.push({...item, _isInside: true}); }
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Then history (completed)
|
| 87 |
+
for (const item of histItems) {
|
| 88 |
+
const s = item.session || item;
|
| 89 |
+
const sid = s.id || '';
|
| 90 |
+
if (sid && !seen.has(sid)) { seen.add(sid); allItems.push({...item, _isInside: false}); }
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Update stats
|
| 94 |
+
// Update stats
|
| 95 |
+
const unregistered = allItems.filter(i => {
|
| 96 |
+
const v = i.vehicle || {};
|
| 97 |
+
return !v.id && !v.plate_letters_ar;
|
| 98 |
+
}).length;
|
| 99 |
+
const registered = allItems.length - unregistered;
|
| 100 |
+
const insideCount = allItems.filter(i => i._isInside).length;
|
| 101 |
+
|
| 102 |
+
document.getElementById('statTotal').textContent = allItems.length;
|
| 103 |
+
document.getElementById('statActive').textContent = insideCount;
|
| 104 |
+
document.getElementById('statRegistered').textContent = registered;
|
| 105 |
+
document.getElementById('statUnregistered').textContent = unregistered;
|
| 106 |
+
|
| 107 |
+
renderVehicles(allItems);
|
| 108 |
+
} catch(e) { console.error(e); }
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function formatDuration(mins) {
|
| 112 |
+
if (mins == null) return 'β';
|
| 113 |
+
const h = Math.floor(mins / 60);
|
| 114 |
+
const m = Math.floor(mins % 60);
|
| 115 |
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function renderVehicles(items) {
|
| 119 |
+
const body = document.getElementById('vehBody');
|
| 120 |
+
if (!items || !items.length) {
|
| 121 |
+
body.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">No vehicles found</td></tr>';
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
body.innerHTML = items.map(item => {
|
| 125 |
+
const s = item.session || item;
|
| 126 |
+
const v = item.vehicle || {};
|
| 127 |
+
const p = item.pricing || {};
|
| 128 |
+
|
| 129 |
+
const plateAr = s.plate_arabic || ((v.plate_letters_ar||'') + ' ' + (v.plate_numbers_ar||'')).trim() || 'β';
|
| 130 |
+
|
| 131 |
+
// Attempt to extract username from backend's profile if available (depends on API return shape)
|
| 132 |
+
// Sometimes backend attaches `owner` to the vehicle object
|
| 133 |
+
let ownerName = 'β';
|
| 134 |
+
if (v.owner) {
|
| 135 |
+
ownerName = v.owner.username || v.owner.full_name || v.owner.first_name || 'β';
|
| 136 |
+
} else if (v.owner_id) {
|
| 137 |
+
ownerName = 'Registered';
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const isGuest = !v.id && !v.plate_letters_ar;
|
| 141 |
+
if (isGuest) {
|
| 142 |
+
ownerName = '<span class="status status-unregistered" style="font-size:0.7rem;padding:2px 6px;">Unregistered</span>';
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const isInside = item._isInside;
|
| 146 |
+
const status = isInside ? 'Inside' : 'Left';
|
| 147 |
+
const stClass = isInside ? 'status-active' : 'status-unregistered';
|
| 148 |
+
|
| 149 |
+
const duration = formatDuration(p.duration_minutes);
|
| 150 |
+
const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β';
|
| 151 |
+
|
| 152 |
+
// Payment badge
|
| 153 |
+
const notes = (s.notes || '').toLowerCase();
|
| 154 |
+
const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
|
| 155 |
+
let isCash = isGuest || hasCashIntent;
|
| 156 |
+
let isPaidVisa = s.payment_confirmed_at != null && !isCash;
|
| 157 |
+
|
| 158 |
+
let payBadge = 'β';
|
| 159 |
+
if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">π³ Visa</span>';
|
| 160 |
+
else if (isCash) payBadge = '<span style="color:var(--accent-green);">π΅ Cash</span>';
|
| 161 |
+
else if (isInside) payBadge = 'β³ Pending';
|
| 162 |
+
|
| 163 |
+
return `<tr>
|
| 164 |
+
<td class="mono">${plateAr}</td>
|
| 165 |
+
<td>${ownerName}</td>
|
| 166 |
+
<td><span class="status ${stClass}">${status}</span></td>
|
| 167 |
+
<td>${duration}</td><td>${fee}</td>
|
| 168 |
+
<td>${payBadge}</td>
|
| 169 |
+
</tr>`;
|
| 170 |
+
}).join('');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
loadVehicles();
|
| 174 |
+
</script>
|
| 175 |
+
{% endblock %}
|
templates/auth/login.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Login β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="auth-wrapper">
|
| 5 |
+
<div class="auth-card fade-in">
|
| 6 |
+
<div class="auth-logo">
|
| 7 |
+
<div class="auth-logo-icon">
|
| 8 |
+
<img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
|
| 9 |
+
</div>
|
| 10 |
+
<h1>Ain El Aql</h1>
|
| 11 |
+
<p>EALPR Security Dashboard</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 15 |
+
{% if messages %}
|
| 16 |
+
{% for cat, msg in messages %}
|
| 17 |
+
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
| 18 |
+
{% endfor %}
|
| 19 |
+
{% endif %}
|
| 20 |
+
{% endwith %}
|
| 21 |
+
|
| 22 |
+
<form method="POST" action="{{ url_for('auth.login_page') }}" id="loginForm">
|
| 23 |
+
<div class="form-group">
|
| 24 |
+
<label class="form-label" for="identifier">Username / Email / Phone</label>
|
| 25 |
+
<input type="text" class="form-input" id="identifier" name="identifier"
|
| 26 |
+
placeholder="Enter your identifier" required autocomplete="username">
|
| 27 |
+
</div>
|
| 28 |
+
<div class="form-group">
|
| 29 |
+
<label class="form-label" for="password">Password</label>
|
| 30 |
+
<input type="password" class="form-input" id="password" name="password"
|
| 31 |
+
placeholder="Enter your password" required autocomplete="current-password">
|
| 32 |
+
</div>
|
| 33 |
+
<button type="submit" class="btn btn-primary w-full" id="loginBtn">
|
| 34 |
+
<span>π</span> Sign In
|
| 35 |
+
</button>
|
| 36 |
+
</form>
|
| 37 |
+
|
| 38 |
+
<div class="auth-footer">
|
| 39 |
+
Want to work with Ain El Aql? <a href="{{ url_for('auth.register_page') }}">Join Us</a>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
{% endblock %}
|
templates/auth/register.html
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Register β Ain El Aql{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="auth-wrapper">
|
| 5 |
+
<div class="auth-card fade-in">
|
| 6 |
+
<div class="auth-logo">
|
| 7 |
+
<div class="auth-logo-icon">
|
| 8 |
+
<img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
|
| 9 |
+
</div>
|
| 10 |
+
<h1>Create Account</h1>
|
| 11 |
+
<p>Join the Ain El Aql platform</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 15 |
+
{% if messages %}
|
| 16 |
+
{% for cat, msg in messages %}
|
| 17 |
+
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
| 18 |
+
{% endfor %}
|
| 19 |
+
{% endif %}
|
| 20 |
+
{% endwith %}
|
| 21 |
+
|
| 22 |
+
<form method="POST" action="{{ url_for('auth.register_page') }}">
|
| 23 |
+
<div class="grid-2">
|
| 24 |
+
<div class="form-group">
|
| 25 |
+
<label class="form-label" for="first_name">First Name</label>
|
| 26 |
+
<input type="text" class="form-input" id="first_name" name="first_name" placeholder="First name">
|
| 27 |
+
</div>
|
| 28 |
+
<div class="form-group">
|
| 29 |
+
<label class="form-label" for="last_name">Last Name</label>
|
| 30 |
+
<input type="text" class="form-input" id="last_name" name="last_name" placeholder="Last name">
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="form-group">
|
| 34 |
+
<label class="form-label" for="username">Username *</label>
|
| 35 |
+
<input type="text" class="form-input" id="username" name="username" placeholder="Choose a username" required>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="form-group">
|
| 38 |
+
<label class="form-label" for="phone">Phone *</label>
|
| 39 |
+
<input type="tel" class="form-input" id="phone" name="phone" placeholder="+20 xxx xxx xxxx" required>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="form-group">
|
| 42 |
+
<label class="form-label" for="reg_email">Email *</label>
|
| 43 |
+
<input type="email" class="form-input" id="reg_email" name="email" placeholder="email@example.com" required>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="form-group">
|
| 46 |
+
<label class="form-label" for="reg_password">Password *</label>
|
| 47 |
+
<input type="password" class="form-input" id="reg_password" name="password" placeholder="Min 6 characters" required>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="form-group">
|
| 50 |
+
<label class="form-label" for="nid">National ID (NID) *</label>
|
| 51 |
+
<input type="text" class="form-input" id="nid" name="nid" placeholder="14-digit National ID" required>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="form-group">
|
| 54 |
+
<label class="form-label" for="role">Select Role *</label>
|
| 55 |
+
<select class="form-select" id="role" name="role" required>
|
| 56 |
+
<option value="security">Security Guard</option>
|
| 57 |
+
<option value="admin">Administrator</option>
|
| 58 |
+
</select>
|
| 59 |
+
</div>
|
| 60 |
+
<button type="submit" class="btn btn-primary w-full">
|
| 61 |
+
<span>β¨</span> Create Account
|
| 62 |
+
</button>
|
| 63 |
+
</form>
|
| 64 |
+
|
| 65 |
+
<div class="auth-footer">
|
| 66 |
+
Already have an account? <a href="{{ url_for('auth.login_page') }}">Sign In</a>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<meta name="description" content="Ain El Aql β EALPR Parking Security & Admin Dashboard">
|
| 7 |
+
<title>{% block title %}Ain El Aql{% endblock %}</title>
|
| 8 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 9 |
+
<link rel="icon" href="{{ url_for('static', filename='assets/cctv_logo.png') }}">
|
| 10 |
+
{% block head %}{% endblock %}
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
{% block body %}{% endblock %}
|
| 14 |
+
{% block scripts %}{% endblock %}
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
templates/partials/sidebar.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Sidebar partial β included in dashboard layouts -->
|
| 2 |
+
<aside class="sidebar">
|
| 3 |
+
<div class="sidebar-header">
|
| 4 |
+
<div class="sidebar-logo">
|
| 5 |
+
<img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
|
| 6 |
+
</div>
|
| 7 |
+
<div>
|
| 8 |
+
<div class="sidebar-title">Ain El Aql</div>
|
| 9 |
+
<div class="sidebar-subtitle">{{ role|upper }} Panel</div>
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<nav class="sidebar-nav">
|
| 14 |
+
{% if role == 'security' %}
|
| 15 |
+
<a href="{{ url_for('security.dashboard') }}" class="nav-link {% if active == 'dashboard' %}active{% endif %}">
|
| 16 |
+
<span class="icon">π</span> Dashboard
|
| 17 |
+
</a>
|
| 18 |
+
|
| 19 |
+
{% elif role == 'admin' %}
|
| 20 |
+
<a href="{{ url_for('admin.dashboard') }}" class="nav-link {% if active == 'dashboard' %}active{% endif %}">
|
| 21 |
+
<span class="icon">π</span> Dashboard
|
| 22 |
+
</a>
|
| 23 |
+
<a href="{{ url_for('admin.garage') }}" class="nav-link {% if active == 'garage' %}active{% endif %}">
|
| 24 |
+
<span class="icon">ποΈ</span> Garage Status
|
| 25 |
+
</a>
|
| 26 |
+
<a href="{{ url_for('admin.users') }}" class="nav-link {% if active == 'users' %}active{% endif %}">
|
| 27 |
+
<span class="icon">π₯</span> Users
|
| 28 |
+
</a>
|
| 29 |
+
<a href="{{ url_for('admin.vehicles') }}" class="nav-link {% if active == 'vehicles' %}active{% endif %}">
|
| 30 |
+
<span class="icon">π</span> Vehicles
|
| 31 |
+
</a>
|
| 32 |
+
<a href="{{ url_for('admin.history') }}" class="nav-link {% if active == 'history' %}active{% endif %}">
|
| 33 |
+
<span class="icon">π</span> History
|
| 34 |
+
</a>
|
| 35 |
+
{% endif %}
|
| 36 |
+
</nav>
|
| 37 |
+
|
| 38 |
+
<div class="sidebar-footer">
|
| 39 |
+
<div class="user-badge">
|
| 40 |
+
<div class="user-avatar">{{ (user.first_name or user.username or '?')[0]|upper }}</div>
|
| 41 |
+
<div>
|
| 42 |
+
<div class="user-name">{{ user.first_name or user.username or 'Staff' }}</div>
|
| 43 |
+
<div class="user-role">{{ role }}</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
<a href="{{ url_for('auth.logout') }}" class="nav-link mt-2" style="color:var(--accent-red);">
|
| 47 |
+
<span class="icon">πͺ</span> Sign Out
|
| 48 |
+
</a>
|
| 49 |
+
</div>
|
| 50 |
+
</aside>
|