Compare commits

..

67 Commits

Author SHA1 Message Date
211db514dd Merge pull request 'local-flights' (#5) from local-flights into main
Reviewed-on: #5
2025-12-20 12:29:31 -05:00
24971ac5fc Merge pull request 'Xmas silliness' (#4) from main into local-flights
Reviewed-on: #4
2025-12-19 12:07:44 -05:00
James Pattinson
a1a5f90f00 Xmas silliness 2025-12-19 17:06:48 +00:00
97995fa58e Help text 2025-12-19 09:07:36 -05:00
bcd582aee5 Filtering enhancements 2025-12-19 08:53:47 -05:00
dc6b551325 Correct missing funcs 2025-12-19 08:40:06 -05:00
ac29b6e929 UI config 2025-12-19 08:33:42 -05:00
0149f45893 AI tweaking 2025-12-19 08:06:47 -05:00
63564b54dd Overflight improvements 2025-12-19 05:51:55 -05:00
3ab9a6e04c Overflights reporting and menu layout changes 2025-12-19 05:28:17 -05:00
b46a88d471 Overflights implementation 2025-12-19 05:07:46 -05:00
658d4c4ff8 Movement summary 2025-12-18 11:53:44 -05:00
a43ab34a8f Better movement reporting 2025-12-18 11:45:24 -05:00
dee5d38b58 URL tidy 2025-12-18 10:46:18 -05:00
ee311cc120 Merge pull request 'Remove Full Tanks option' (#3) from main into local-flights
Reviewed-on: #3
2025-12-18 10:35:00 -05:00
James Pattinson
e63fdc74ec Remove Full Tanks option 2025-12-18 15:32:39 +00:00
a8c0a37b7e Departed Today cleanup 2025-12-18 10:25:57 -05:00
c92f838489 Fix display race condition 2025-12-18 10:17:26 -05:00
James Pattinson
8513a7bb0f Alembix fixes 2025-12-18 14:59:25 +00:00
d183678282 Small bugfixes 2025-12-18 08:10:57 -05:00
a2682314c9 Journaling for all flights 2025-12-18 07:34:19 -05:00
f3eb83665f Edit modal changes 2025-12-18 06:53:30 -05:00
f572fb75f5 Added local flight duration 2025-12-18 06:27:22 -05:00
f65c54109e Public board fixes 2025-12-18 04:54:19 -05:00
d53ddff4be List circuits 2025-12-17 04:20:12 -05:00
2d4f1467de Circuits handling 2025-12-16 12:59:43 -05:00
65eb3272f2 PPR indication 2025-12-16 12:40:02 -05:00
6209c7acce Book in functions 2025-12-16 09:47:26 -05:00
d7eefdb652 Lookup enhancements 2025-12-16 09:10:45 -05:00
98d0e3cfd7 Help text 2025-12-16 07:15:48 -05:00
d2e7d3c3dd Booking out improvements 2025-12-16 06:41:18 -05:00
ea35de5eb5 Cancel Confirm 2025-12-12 12:24:33 -05:00
97517777df ETD for Locals 2025-12-12 12:19:31 -05:00
ab3319af06 Feature enhancement 2025-12-12 12:11:00 -05:00
32ad7a793a Separate CSS 2025-12-12 11:24:47 -05:00
dbb285fa20 Getting there 2025-12-12 11:18:28 -05:00
f7467690e4 Public board for local fligts 2025-12-12 06:50:38 -05:00
1d1c504f91 Callsign fix 2025-12-12 06:24:23 -05:00
0aeed2268a Inital stab at local flights 2025-12-12 06:14:36 -05:00
56e4ab6e3e Merge pull request 'main' (#2) from main into local-flights
Reviewed-on: #2
2025-12-12 05:20:18 -05:00
ee1b42442e Merge pull request 'change-password' (#1) from change-password into main
Reviewed-on: #1
2025-12-11 12:46:28 -05:00
cc5697eaa0 Add GET for users 2025-12-11 12:42:27 -05:00
9cfd88d848 Initial go 2025-12-11 12:37:11 -05:00
James Pattinson
7efc2ef37a Usability fixes 2025-12-11 15:42:21 +00:00
James Pattinson
5f2aa82e36 Push to prod 2025-12-10 16:40:58 +00:00
James Pattinson
e8bd30aadc Future PPRs 2025-12-10 15:44:26 +00:00
James Pattinson
bd1200f377 Session timeout issue and Extra tables 2025-12-10 14:02:31 +00:00
James Pattinson
f4b69aace0 Info display tweaks 2025-12-10 13:29:12 +00:00
James Pattinson
86f1dc65f4 Public display improvements WIP 2025-12-10 11:25:33 +00:00
James Pattinson
169c3af29b Date issues 2025-12-10 10:45:34 +00:00
James Pattinson
11f7390694 Realtive URL fix 2025-12-10 10:10:13 +00:00
James Pattinson
39d5c2c9e2 Daily arrivals fix 2025-12-07 15:10:11 +00:00
James Pattinson
4d71d59d90 Gone live 2025-12-07 15:02:51 +00:00
James Pattinson
3780b3cf2f External DB init changes 2025-12-04 18:29:09 +00:00
James Pattinson
b6ad496cf0 Lots of changes to support Alembic and external DB 2025-12-04 17:54:49 +00:00
James Pattinson
d33ad725cb Pre-prod tweaks 2025-12-04 17:21:55 +00:00
James Pattinson
b2a6545ace Parameterisation of python settings 2025-10-25 15:27:32 +00:00
James Pattinson
77b5080bbd Doc update and SQL init 2025-10-25 15:10:54 +00:00
James Pattinson
7643c179fb Reporting page 2025-10-25 14:23:52 +00:00
James Pattinson
023c238cee Test data script 2025-10-25 14:09:43 +00:00
James Pattinson
6e760a3e96 New date picker 2025-10-25 13:57:19 +00:00
James Pattinson
d5f05941c9 Email notification 2025-10-25 13:31:03 +00:00
James Pattinson
91e820b9a8 Public PPR submission 2025-10-25 12:59:07 +00:00
James Pattinson
b6e32eccad New date picker 2025-10-24 18:07:45 +00:00
James Pattinson
9d77e11627 little fixes 2025-10-24 17:53:01 +00:00
James Pattinson
1223d9e9f9 Menu tidy 2025-10-24 17:36:59 +00:00
James Pattinson
41c7bb352a ICAO code expansion 2025-10-23 20:31:23 +00:00
78 changed files with 13859 additions and 1507 deletions

View File

@@ -2,7 +2,17 @@
General Information General Information
As you perform more tests, document in this file the steps you are taking to avoid repetition. As you perform more tests, document in this file the steps you are taking to avoid repetition.
## System Overview
The NextGen PPR system includes:
- **FastAPI Backend** with comprehensive REST API
- **MySQL Database** with full PPR tracking and audit trails
- **Web Interfaces** for public submission, admin management, and reporting
- **Email Notifications** for PPR submissions and cancellations
- **Real-time Updates** via WebSocket for live tower operations
- **Test Data Generation** utilities for development and testing
## API Base URL ## API Base URL
- Development: `http://localhost:8001/api/v1` - Development: `http://localhost:8001/api/v1`
@@ -145,6 +155,58 @@ curl -X POST "http://localhost:8001/api/v1/pprs/" \
**Endpoint:** `DELETE /api/v1/pprs/{ppr_id}` (soft delete - sets status to DELETED) **Endpoint:** `DELETE /api/v1/pprs/{ppr_id}` (soft delete - sets status to DELETED)
## User Management Endpoints (Admin Only)
### List Users
**Endpoint:** `GET /api/v1/auth/users`
### Create User
**Endpoint:** `POST /api/v1/auth/users`
**Request Body:**
```json
{
"username": "newuser",
"password": "securepassword",
"role": "OPERATOR"
}
```
**Available Roles:**
- `ADMINISTRATOR`: Full access including user management
- `OPERATOR`: PPR management access
- `READ_ONLY`: View-only access
### Get User Details
**Endpoint:** `GET /api/v1/auth/users/{user_id}`
### Update User
**Endpoint:** `PUT /api/v1/auth/users/{user_id}`
### Delete User
**Endpoint:** `DELETE /api/v1/auth/users/{user_id}`
## Reference Data Endpoints
### Lookup Airport
**Endpoint:** `GET /api/v1/airport/lookup/{icao_code}`
Returns airport details for the given ICAO code.
### Lookup Aircraft
**Endpoint:** `GET /api/v1/aircraft/lookup/{registration}`
Returns aircraft details for the given registration.
## Reference Data Endpoints
## Public Endpoints (No Authentication Required) ## Public Endpoints (No Authentication Required)
### Get Today's Arrivals ### Get Today's Arrivals
@@ -159,6 +221,40 @@ Returns all PPRs with status "NEW" and ETA for today.
Returns all PPRs with status "LANDED" and ETD for today. Returns all PPRs with status "LANDED" and ETD for today.
### Submit Public PPR
**Endpoint:** `POST /api/v1/public/pprs/`
**Headers:**
- `Content-Type: application/json`
**Request Body:** Same as authenticated PPR creation (see above)
**Notes:**
- No authentication required
- Sends confirmation email if email provided
- Returns PPR with public edit token
### Get PPR for Public Editing
**Endpoint:** `GET /api/v1/public/edit/{token}`
Returns PPR details for public editing using the token from email.
### Update PPR Publicly
**Endpoint:** `PATCH /api/v1/public/edit/{token}`
**Request Body:** Same as authenticated PPR update
**Notes:** Only allowed if PPR status is not processed (not LANDED, DEPARTED, CANCELED, or DELETED)
### Cancel PPR Publicly
**Endpoint:** `DELETE /api/v1/public/cancel/{token}`
Cancels PPR using public token and sends cancellation email.
## Data Models ## Data Models
### PPR Status Enum ### PPR Status Enum
@@ -256,10 +352,14 @@ The system automatically logs:
## Web Interfaces ## Web Interfaces
### Public Arrivals/Departures Board ### Public PPR Forms
- **URL:** http://localhost:8082 - **URL:** http://localhost:8082
- **Features:** Real-time arrivals and departures display - **Features:**
- **Authentication:** None required - PPR submission with intelligent aircraft/airport field lookups
- Date/time pickers with 15-minute intervals
- Email notifications for submissions
- Public editing/cancellation via secure email tokens
- Real-time validation and feedback
### Admin Interface ### Admin Interface
- **URL:** http://localhost:8082/admin.html - **URL:** http://localhost:8082/admin.html
@@ -270,15 +370,80 @@ The system automatically logs:
- Journal/audit trail viewing - Journal/audit trail viewing
- Quick status updates (Confirm, Land, Depart, Cancel) - Quick status updates (Confirm, Land, Depart, Cancel)
- New PPR entry creation - New PPR entry creation
- User management (administrators only)
- Real-time WebSocket updates
- **Authentication:** Username/password prompt (uses API token) - **Authentication:** Username/password prompt (uses API token)
### Reports Interface
- **URL:** http://localhost:8082/reports.html
- **Features:**
- Comprehensive PPR reporting with date range filtering (defaults to current month)
- Search across aircraft registration, callsign, captain name, and airports
- Status-based filtering
- CSV and XLS export functionality
- Responsive table displaying all PPR fields
- Real-time data loading with authentication
## Development URLs ## Development URLs
- API Base: http://localhost:8001/api/v1 - API Base: http://localhost:8001/api/v1
- Public Web Interface: http://localhost:8082 - Public Web Interface: http://localhost:8082
- Admin Interface: http://localhost:8082/admin.html - Admin Interface: http://localhost:8082/admin.html
- Reports Interface: http://localhost:8082/reports.html
- API Documentation: http://localhost:8001/docs - API Documentation: http://localhost:8001/docs
- Database: localhost:3307 (MySQL) - Database: localhost:3307 (MySQL)
- phpMyAdmin: http://localhost:8083
## Email System
The system includes transactional email support:
### Configuration
Email settings are configured via environment variables:
- `SMTP_SERVER`: SMTP server hostname
- `SMTP_PORT`: SMTP server port (default: 587)
- `SMTP_USERNAME`: SMTP authentication username
- `SMTP_PASSWORD`: SMTP authentication password
- `FROM_EMAIL`: Sender email address
- `BASE_URL`: Base URL for email links
### Email Templates
- **PPR Submitted**: Sent when public PPR is created
- **PPR Cancelled**: Sent when PPR is cancelled (by user or admin)
### Email Content
Emails include:
- PPR details (aircraft, times, contact info)
- Secure edit/cancel links with tokens
- Formatted HTML with system branding
## Test Data Generation
The system includes comprehensive test data generation utilities:
### Generate Test PPRs
```bash
# Run from host
./populate_test_data.sh
# Or run directly in container
docker exec -it ppr_nextgen_api python populate_test_data.py
```
### What It Creates
- **93 PPR records** across all statuses (NEW, CONFIRMED, LANDED, DEPARTED, CANCELED)
- **Diverse aircraft types** from the loaded aircraft database
- **Realistic airport combinations** from the loaded airport data
- **Varied captains and contact details**
- **Proper date/time distributions** across different time periods
- **Status distribution** matching real-world usage patterns
### Test Data Features
- Aircraft registrations with proper formatting
- Realistic passenger counts and fuel requirements
- Geographic distribution across UK airports
- Time-based distribution for testing date filters
- Email addresses for testing notification system
## Example Workflow ## Example Workflow
@@ -309,4 +474,90 @@ curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-d '{"status":"LANDED"}' -d '{"status":"LANDED"}'
```
# Test public PPR submission (no auth required)
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
-H "Content-Type: application/json" \
-d '{"ac_reg":"G-PUBLIC","ac_type":"PA28","captain":"Public User","in_from":"EGGW","eta":"2025-10-21T15:00:00","pob_in":1,"email":"test@example.com"}'
# Test filtering by date range
curl -s "http://localhost:8001/api/v1/pprs/?date_from=2025-10-01&date_to=2025-10-31&limit=10" \
-H "Authorization: Bearer $TOKEN" | jq .
# Test user management (admin only)
curl -s "http://localhost:8001/api/v1/auth/users" \
-H "Authorization: Bearer $TOKEN" | jq .
# Generate test data
docker exec -it ppr_nextgen_api python populate_test_data.py
```
## Enhanced Features
### Intelligent Form Lookups
- **Aircraft Lookup**: Auto-suggests aircraft type based on registration
- **Airport Lookup**: Provides airport name and location for ICAO codes
- **Real-time Validation**: Immediate feedback on form inputs
### Advanced Date/Time Handling
- **15-Minute Intervals**: All time pickers use 15-minute increments
- **Separate Date/Time Fields**: Improved UX with dedicated date and time inputs
- **UTC Storage**: All times stored in UTC for consistency
### Comprehensive Reporting
- **Date Range Filtering**: Filter PPRs by custom date ranges
- **Multi-field Search**: Search across registration, callsign, captain, airports
- **Export Functionality**: CSV and XLS download with all PPR data
- **Responsive Design**: Works on desktop and mobile devices
### Audit Trail System
- **Complete Change History**: Every field change is logged
- **User Tracking**: Who made changes and when
- **IP Address Logging**: Security tracking for all actions
- **Automatic Journal Entries**: No manual logging required
## Example Complete Workflow
1. **Public Submission**
```bash
# User submits PPR via public form
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
-H "Content-Type: application/json" \
-d '{"ac_reg":"G-WORKFLOW","ac_type":"C152","captain":"Workflow Test","in_from":"EGKB","eta":"2025-10-21T14:00:00","pob_in":1,"email":"workflow@example.com"}'
```
2. **Email Notification Sent**
- User receives confirmation email with edit/cancel links
3. **Admin Processing**
```bash
# Admin confirms PPR
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"status":"CONFIRMED"}'
```
4. **Status Updates**
```bash
# Mark as landed
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"status":"LANDED","timestamp":"2025-10-21T14:05:00Z"}'
# Mark as departed
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"status":"DEPARTED","timestamp":"2025-10-21T15:30:00Z"}'
```
5. **Public Editing/Cancellation**
- User can edit or cancel using token from email
- System sends cancellation email if cancelled
6. **Reporting**
- Access reports at http://localhost:8082/reports.html
- Filter by date range, status, search terms
- Export to CSV/XLS for analysis

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# Database Configuration
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here
DB_USER=your_database_user_here
DB_PASSWORD=your_database_password_here
DB_NAME=your_database_name_here
DB_PORT=3306
# API Configuration
DB_HOST=db
SECRET_KEY=your_secret_key_here_change_in_production
ACCESS_TOKEN_EXPIRE_MINUTES=30
API_V1_STR=/api/v1
PROJECT_NAME=Airfield PPR API NextGen
API_PORT_EXTERNAL=8001
# Mail Configuration
MAIL_HOST=your_mail_host_here
MAIL_PORT=465
MAIL_USERNAME=your_mail_username_here
MAIL_PASSWORD=your_mail_password_here
MAIL_FROM=your_mail_from_address_here
MAIL_FROM_NAME=your_mail_from_name_here
# Application settings
BASE_URL=your_base_url_here
# UI Configuration
TAG=
TOP_BAR_BASE_COLOR=#2c3e50
ENVIRONMENT=development
# Redis (optional)
REDIS_URL=
# Web Configuration
WEB_PORT_EXTERNAL=8082
# phpMyAdmin Configuration
PMA_HOST=db
UPLOAD_LIMIT=50M
PMA_PORT_EXTERNAL=8083

7
.gitignore vendored
View File

@@ -30,6 +30,10 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Alembic - keep migration files but not temporary files
# backend/alembic/versions/*.pyc
# backend/alembic/__pycache__/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
@@ -68,6 +72,9 @@ logs/
*.temp *.temp
.cache/ .cache/
# Generated config files
web/config.js
# Coverage reports # Coverage reports
htmlcov/ htmlcov/
.coverage .coverage

167
README.md
View File

@@ -12,32 +12,88 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
## Features ## Features
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation - 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
- 🔐 **Authentication**: JWT-based authentication system - 🔐 **Authentication**: JWT-based authentication system with role-based access
- 📊 **Real-time Updates**: WebSocket support for live tower updates - <EFBFBD> **Transactional Email**: SMTP-based email notifications for PPR submissions and cancellations
- 🗄️ **Self-contained**: Fully dockerized with local database - <EFBFBD>📊 **Real-time Updates**: WebSocket support for live tower updates
- <20> **Comprehensive Reporting**: Advanced reporting with filtering, search, and CSV/XLS export
- <20>🗄 **Self-contained**: Fully dockerized with local database
- 🔍 **Documentation**: Auto-generated API docs at `/docs` - 🔍 **Documentation**: Auto-generated API docs at `/docs`
- 🧪 **Testing**: Comprehensive test suite - 🧪 **Testing**: Comprehensive test suite with data population utilities
- 📱 **Mobile Ready**: Responsive design for tower operations - 📱 **Mobile Ready**: Responsive design for tower operations
- 🔄 **Public Forms**: Enhanced public PPR submission and editing with intelligent field lookups
- 📋 **Audit Trail**: Complete journal system tracking all PPR changes
## Quick Start ## Quick Start
### Prerequisites ### Prerequisites
- Docker and Docker Compose installed - Docker and Docker Compose installed
### 1. Start the System ### Development Setup
**Start the system (automatic database setup):**
```bash ```bash
cd nextgen docker compose up -d
./start.sh
``` ```
That's it! The container automatically:
- Waits for database to be ready
- Creates schema via Alembic migrations
- Loads airport and aircraft reference data
- Starts the API with auto-reload
**View startup logs:**
```bash
docker compose logs -f api
```
### Production Deployment
**Simple automated deployment:**
```bash
# 1. Configure environment
cp .env.example .env
nano .env # Set your external database credentials
# 2. Start with production settings
docker compose -f docker-compose.prod.yml up -d
```
The container automatically handles:
- Database connection verification
- Schema creation/migration (Alembic)
- Reference data seeding (if needed)
- Production server startup (4 workers)
**Monitor deployment:**
```bash
docker compose -f docker-compose.prod.yml logs -f api
```
**Deploying updates with migrations:**
```bash
git pull
docker compose -f docker-compose.prod.yml up -d --build
# Migrations apply automatically!
```
**See detailed guides:**
- [`AUTOMATED_MIGRATION_GUIDE.md`](./AUTOMATED_MIGRATION_GUIDE.md) - How automatic migrations work
- [`PRODUCTION_MIGRATION_GUIDE.md`](./PRODUCTION_MIGRATION_GUIDE.md) - Advanced migration strategies
- [`MIGRATION_QUICK_REF.md`](./MIGRATION_QUICK_REF.md) - Quick reference commands
### 2. Access the Services ### 2. Access the Services
- **API Documentation**: http://localhost:8001/docs - **API Documentation**: http://localhost:8001/docs
- **API Base URL**: http://localhost:8001/api/v1 - **API Base URL**: http://localhost:8001/api/v1
- **Database**: localhost:3307 (user: ppr_user, password: ppr_password123) - **Public Web Interface**: http://localhost:8082
- **Admin Interface**: http://localhost:8082/admin.html
- **Reports Interface**: http://localhost:8082/reports.html
- **Database**: localhost:3307 (user: ppr_user, password: [configured in .env])
- **phpMyAdmin**: http://localhost:8083
### 3. Default Login ### 3. Default Login
- **Username**: admin - **Username**: admin
- **Password**: admin123 - **Password**: [configured in database - see init_db.sql]
## API Endpoints ## API Endpoints
@@ -45,20 +101,68 @@ cd nextgen
- `POST /api/v1/auth/login` - Login and get JWT token - `POST /api/v1/auth/login` - Login and get JWT token
### PPR Management ### PPR Management
- `GET /api/v1/pprs` - List PPR records - `GET /api/v1/pprs` - List PPR records (with filtering by status, date range)
- `POST /api/v1/pprs` - Create new PPR - `POST /api/v1/pprs` - Create new PPR
- `GET /api/v1/pprs/{id}` - Get specific PPR - `GET /api/v1/pprs/{id}` - Get specific PPR
- `PUT /api/v1/pprs/{id}` - Update PPR - `PUT /api/v1/pprs/{id}` - Update PPR
- `PATCH /api/v1/pprs/{id}` - Partially update PPR
- `PATCH /api/v1/pprs/{id}/status` - Update PPR status - `PATCH /api/v1/pprs/{id}/status` - Update PPR status
- `DELETE /api/v1/pprs/{id}` - Delete PPR - `DELETE /api/v1/pprs/{id}` - Delete PPR
- `GET /api/v1/pprs/{id}/journal` - Get PPR activity journal
### Public Endpoints (No Auth Required) ### Public Endpoints (No Auth Required)
- `GET /api/v1/public/arrivals` - Today's arrivals - `GET /api/v1/public/arrivals` - Today's arrivals
- `GET /api/v1/public/departures` - Today's departures - `GET /api/v1/public/departures` - Today's departures
- `POST /api/v1/public/pprs` - Submit public PPR
- `GET /api/v1/public/edit/{token}` - Get PPR for public editing
- `PATCH /api/v1/public/edit/{token}` - Update PPR publicly
- `DELETE /api/v1/public/cancel/{token}` - Cancel PPR publicly
### User Management (Admin Only)
- `GET /api/v1/auth/users` - List users
- `POST /api/v1/auth/users` - Create user
- `GET /api/v1/auth/users/{id}` - Get user details
- `PUT /api/v1/auth/users/{id}` - Update user
- `DELETE /api/v1/auth/users/{id}` - Delete user
### Reference Data
- `GET /api/v1/airport/lookup/{code}` - Lookup airport by ICAO code
- `GET /api/v1/aircraft/lookup/{reg}` - Lookup aircraft by registration
### Real-time ### Real-time
- `WebSocket /ws/tower-updates` - Live updates for tower operations - `WebSocket /ws/tower-updates` - Live updates for tower operations
## Web Interfaces
### Public PPR Forms
- **URL**: http://localhost:8082
- **Features**:
- PPR submission form with intelligent aircraft/airport lookups
- Date/time pickers with 15-minute intervals
- Email notifications for submissions
- Public editing/cancellation via secure tokens
### Admin Interface
- **URL**: http://localhost:8082/admin.html
- **Features**:
- Complete PPR management (CRUD operations)
- Advanced filtering by status, date range
- Inline editing with modal interface
- Journal/audit trail viewing
- Quick status updates (Confirm, Land, Depart, Cancel)
- New PPR entry creation
- User management (administrators only)
- Real-time WebSocket updates
### Reports Interface
- **URL**: http://localhost:8082/reports.html
- **Features**:
- Comprehensive PPR reporting with date range filtering
- Search across aircraft, captain, and airport fields
- Status-based filtering
- CSV and XLS export functionality
- Responsive table with all PPR details
## Development ## Development
### Local Development ### Local Development
@@ -73,12 +177,7 @@ uvicorn app.main:app --reload
# Connect to database # Connect to database
docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
# View logs # When prompted for password, use the value from .env (DB_PASSWORD)
docker-compose logs -f api
docker-compose logs -f db
# Restart services
docker-compose restart
``` ```
### Testing ### Testing
@@ -87,6 +186,33 @@ cd backend
pytest tests/ pytest tests/
``` ```
## Additional Features
### Email Notifications
The system includes transactional email support for:
- **PPR Submissions**: Automatic email confirmation to submitters
- **PPR Cancellations**: Notification emails when PPRs are cancelled
- **SMTP Configuration**: Configurable SMTP settings via environment variables
### Test Data Generation
A comprehensive test data population script is included:
```bash
# Generate test PPR records
docker exec -it ppr_nextgen_api python populate_test_data.py
# Or run the convenience script
./populate_test_data.sh
```
This creates diverse PPR records across all statuses with realistic aircraft and airport data for testing purposes.
### Audit Trail
Complete activity logging system tracks:
- All PPR field changes with before/after values
- Status transitions with timestamps
- User actions and IP addresses
- Automatic journal entries for all modifications
## Environment Variables ## Environment Variables
Key environment variables (configured in docker-compose.yml): Key environment variables (configured in docker-compose.yml):
@@ -98,6 +224,15 @@ Key environment variables (configured in docker-compose.yml):
- `SECRET_KEY` - JWT secret key - `SECRET_KEY` - JWT secret key
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time - `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time
### Email Configuration
- `SMTP_SERVER` - SMTP server hostname
- `SMTP_PORT` - SMTP server port (default: 587)
- `SMTP_USERNAME` - SMTP authentication username
- `SMTP_PASSWORD` - SMTP authentication password
- `SMTP_TLS` - Enable TLS (default: true)
- `FROM_EMAIL` - Sender email address
- `BASE_URL` - Base URL for email links
## Database Schema ## Database Schema
The system uses an improved version of the original schema with: The system uses an improved version of the original schema with:

View File

@@ -19,8 +19,12 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Make scripts executable
RUN chmod +x /app/entrypoint.sh && \
chmod +x /app/seed_data.py
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Run the application # Use entrypoint script
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]

114
backend/alembic.ini Normal file
View File

@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Database URL - will be overridden by env.py from environment variables
sqlalchemy.url = mysql+pymysql://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

92
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,92 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Add the app directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.config import settings
from app.db.session import Base
# Import all models to ensure they are registered with Base
from app.models.ppr import PPRRecord, User, Airport, Aircraft
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Override sqlalchemy.url with the one from settings
config.set_main_option('sqlalchemy.url', settings.database_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,165 @@
"""Initial schema baseline
Revision ID: 001_initial_schema
Revises:
Create Date: 2025-12-04 12:00:00.000000
This is the baseline migration that captures the current database schema.
For existing databases, this migration should be marked as applied without running it.
For new databases, this creates the complete initial schema.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '001_initial_schema'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Create all tables for a fresh database installation.
"""
# Users table
op.create_table('users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('role', sa.Enum('ADMINISTRATOR', 'OPERATOR', 'READ_ONLY', name='userrole'), nullable=False),
sa.Column('email', sa.String(length=128), nullable=True),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Integer(), nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_username', 'users', ['username'])
op.create_index('idx_email', 'users', ['email'])
# Main PPR submissions table
op.create_table('submitted',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('status', sa.Enum('NEW', 'CONFIRMED', 'CANCELED', 'LANDED', 'DELETED', 'DEPARTED', name='pprstatus'), nullable=False, server_default='NEW'),
sa.Column('ac_reg', sa.String(length=16), nullable=False),
sa.Column('ac_type', sa.String(length=32), nullable=False),
sa.Column('ac_call', sa.String(length=16), nullable=True),
sa.Column('captain', sa.String(length=64), nullable=False),
sa.Column('fuel', sa.String(length=16), nullable=True),
sa.Column('in_from', sa.String(length=64), nullable=False),
sa.Column('eta', sa.DateTime(), nullable=False),
sa.Column('pob_in', sa.Integer(), nullable=False),
sa.Column('out_to', sa.String(length=64), nullable=True),
sa.Column('etd', sa.DateTime(), nullable=True),
sa.Column('pob_out', sa.Integer(), nullable=True),
sa.Column('email', sa.String(length=128), nullable=True),
sa.Column('phone', sa.String(length=16), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('landed_dt', sa.DateTime(), nullable=True),
sa.Column('departed_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('submitted_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('public_token', sa.String(length=128), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('public_token'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_status', 'submitted', ['status'])
op.create_index('idx_eta', 'submitted', ['eta'])
op.create_index('idx_etd', 'submitted', ['etd'])
op.create_index('idx_ac_reg', 'submitted', ['ac_reg'])
op.create_index('idx_submitted_dt', 'submitted', ['submitted_dt'])
op.create_index('idx_created_by', 'submitted', ['created_by'])
op.create_index('idx_public_token', 'submitted', ['public_token'])
# Activity journal table
op.create_table('journal',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('ppr_id', sa.BigInteger(), nullable=False),
sa.Column('entry', sa.Text(), nullable=False),
sa.Column('user', sa.String(length=50), nullable=False),
sa.Column('ip', sa.String(length=45), nullable=False),
sa.Column('entry_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['ppr_id'], ['submitted.id'], ondelete='CASCADE'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_ppr_id', 'journal', ['ppr_id'])
op.create_index('idx_entry_dt', 'journal', ['entry_dt'])
op.create_index('idx_user', 'journal', ['user'])
# Airports reference table
op.create_table('airports',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('icao', sa.String(length=4), nullable=False),
sa.Column('iata', sa.String(length=3), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('country', sa.String(length=100), nullable=False),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('timezone', sa.String(length=50), nullable=True),
sa.Column('latitude', mysql.DECIMAL(precision=10, scale=8), nullable=True),
sa.Column('longitude', mysql.DECIMAL(precision=11, scale=8), nullable=True),
sa.Column('elevation', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('icao', name='unique_icao'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_iata', 'airports', ['iata'])
op.create_index('idx_country', 'airports', ['country'])
op.create_index('idx_name', 'airports', ['name'])
# Aircraft reference table
op.create_table('aircraft',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('icao24', sa.String(length=6), nullable=True),
sa.Column('registration', sa.String(length=25), nullable=True),
sa.Column('manufacturer_icao', sa.String(length=50), nullable=True),
sa.Column('type_code', sa.String(length=30), nullable=True),
sa.Column('manufacturer_name', sa.String(length=255), nullable=True),
sa.Column('model', sa.String(length=255), nullable=True),
sa.Column('clean_reg', sa.String(length=25), sa.Computed("UPPER(REPLACE(REPLACE(registration, '-', ''), ' ', ''))", persisted=True), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_registration', 'aircraft', ['registration'])
op.create_index('idx_clean_reg', 'aircraft', ['clean_reg'])
op.create_index('idx_icao24', 'aircraft', ['icao24'])
op.create_index('idx_type_code', 'aircraft', ['type_code'])
# Insert default admin user (password: admin123)
# This should be changed immediately in production
op.execute("""
INSERT INTO users (username, password, role, email, full_name) VALUES
('admin', '$2b$12$BJOha2yRxkxuHL./BaMfpu2fMDgGMYISuRV2.B1sSklVpRjz3Y4a6', 'ADMINISTRATOR', 'admin@ppr.local', 'System Administrator')
""")
def downgrade() -> None:
"""
Drop all tables - USE WITH CAUTION IN PRODUCTION
"""
op.drop_table('journal')
op.drop_table('submitted')
op.drop_table('users')
op.drop_table('aircraft')
op.drop_table('airports')

View File

@@ -0,0 +1,216 @@
"""Add local_flights table for tracking local flights
Revision ID: 002_local_flights
Revises: 001_initial_schema
Create Date: 2025-12-12 12:00:00.000000
This migration adds a new table for tracking local flights (circuits, local, departure)
that don't require PPR submissions. Also adds etd and renames booked_out_dt to created_dt,
and departure_dt to departed_dt for consistency. Transforms journal table from PPR-specific
to a generic polymorphic journal for all entity types.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_local_flights'
down_revision = '001_initial_schema'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Create local_flights, departures, and arrivals tables.
Transform journal table from PPR-specific to generic polymorphic journal.
"""
# Modify existing journal table to support all entity types
# First add new columns (check if they don't already exist)
from sqlalchemy import inspect, text
from alembic import op
# Get table columns to check if entity_type and entity_id already exist
connection = op.get_context().bind
inspector = inspect(connection)
columns = [col['name'] for col in inspector.get_columns('journal')]
if 'entity_type' not in columns:
op.add_column('journal', sa.Column('entity_type', sa.String(50), nullable=True))
if 'entity_id' not in columns:
op.add_column('journal', sa.Column('entity_id', sa.BigInteger(), nullable=True))
# Migrate existing PPR journal entries: backfill entity_type and entity_id
op.execute("""
UPDATE journal SET
entity_type = 'PPR',
entity_id = ppr_id
WHERE entity_type IS NULL AND ppr_id IS NOT NULL
""")
# Make new columns NOT NULL after migration
op.alter_column('journal', 'entity_type', existing_type=sa.String(50), nullable=False)
op.alter_column('journal', 'entity_id', existing_type=sa.BigInteger(), nullable=False)
# Make ip column nullable (new entries won't always have it)
op.alter_column('journal', 'ip', existing_type=sa.String(45), nullable=True)
# Drop the foreign key constraint before dropping the column
if 'ppr_id' in columns:
op.drop_constraint('journal_ibfk_1', 'journal', type_='foreignkey')
op.drop_column('journal', 'ppr_id')
# Add composite index for efficient queries
op.create_index('idx_entity_lookup', 'journal', ['entity_type', 'entity_id'])
# Drop old index if it exists
try:
op.drop_index('idx_ppr_id', table_name='journal')
except:
pass
op.create_table('local_flights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'),
sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'),
sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('etd', sa.DateTime(), nullable=True),
sa.Column('departed_dt', sa.DateTime(), nullable=True),
sa.Column('landed_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
# Create indexes for local_flights
op.create_index('idx_registration', 'local_flights', ['registration'])
op.create_index('idx_flight_type', 'local_flights', ['flight_type'])
op.create_index('idx_status', 'local_flights', ['status'])
op.create_index('idx_created_dt', 'local_flights', ['created_dt'])
op.create_index('idx_etd', 'local_flights', ['etd'])
op.create_index('idx_created_by', 'local_flights', ['created_by'])
# Create departures table for non-PPR departures to other airports
op.create_table('departures',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('out_to', sa.String(length=64), nullable=False),
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'CANCELLED', name='departuresstatus'), nullable=False, server_default='BOOKED_OUT'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('etd', sa.DateTime(), nullable=True),
sa.Column('departed_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_dep_registration', 'departures', ['registration'])
op.create_index('idx_dep_out_to', 'departures', ['out_to'])
op.create_index('idx_dep_status', 'departures', ['status'])
op.create_index('idx_dep_created_dt', 'departures', ['created_dt'])
op.create_index('idx_dep_etd', 'departures', ['etd'])
op.create_index('idx_dep_created_by', 'departures', ['created_by'])
# Create arrivals table for non-PPR arrivals from elsewhere
op.create_table('arrivals',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('in_from', sa.String(length=64), nullable=False),
sa.Column('status', sa.Enum('BOOKED_IN', 'LANDED', 'CANCELLED', name='arrivalsstatus'), nullable=False, server_default='BOOKED_IN'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('eta', sa.DateTime(), nullable=True),
sa.Column('landed_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_arr_registration', 'arrivals', ['registration'])
op.create_index('idx_arr_in_from', 'arrivals', ['in_from'])
op.create_index('idx_arr_status', 'arrivals', ['status'])
op.create_index('idx_arr_created_dt', 'arrivals', ['created_dt'])
op.create_index('idx_arr_eta', 'arrivals', ['eta'])
op.create_index('idx_arr_created_by', 'arrivals', ['created_by'])
# Create circuits table for tracking touch-and-go events during circuit training
op.create_table('circuits',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('local_flight_id', sa.BigInteger(), nullable=False),
sa.Column('circuit_timestamp', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['local_flight_id'], ['local_flights.id'], ondelete='CASCADE'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
# Create indexes for circuits
op.create_index('idx_circuit_local_flight_id', 'circuits', ['local_flight_id'])
op.create_index('idx_circuit_timestamp', 'circuits', ['circuit_timestamp'])
# Create overflights table for tracking aircraft talking to the tower but not departing/landing
op.create_table('overflights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('pob', sa.Integer(), nullable=True),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('departure_airfield', sa.String(length=64), nullable=True),
sa.Column('destination_airfield', sa.String(length=64), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'CANCELLED', name='overflightstatus'), nullable=False, server_default='ACTIVE'),
sa.Column('call_dt', sa.DateTime(), nullable=False),
sa.Column('qsy_dt', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
# Create indexes for overflights
op.create_index('idx_ovf_registration', 'overflights', ['registration'])
op.create_index('idx_ovf_departure_airfield', 'overflights', ['departure_airfield'])
op.create_index('idx_ovf_destination_airfield', 'overflights', ['destination_airfield'])
op.create_index('idx_ovf_status', 'overflights', ['status'])
op.create_index('idx_ovf_call_dt', 'overflights', ['call_dt'])
op.create_index('idx_ovf_created_dt', 'overflights', ['created_dt'])
op.create_index('idx_ovf_created_by', 'overflights', ['created_by'])
def downgrade() -> None:
"""
Drop the overflights, circuits, arrivals, departures, and local_flights tables.
"""
op.drop_table('overflights')
op.drop_table('circuits')
op.drop_table('arrivals')
op.drop_table('departures')
op.drop_table('local_flights')

View File

@@ -1,10 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"]) api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"])
api_router.include_router(local_flights.router, prefix="/local-flights", tags=["local_flights"])
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"])
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(public.router, prefix="/public", tags=["public"]) api_router.include_router(public.router, prefix="/public", tags=["public"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"]) api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"]) api_router.include_router(airport.router, prefix="/airport", tags=["airport"])

View File

@@ -33,6 +33,30 @@ async def lookup_aircraft_by_registration(
return aircraft_list return aircraft_list
@router.get("/public/lookup/{registration}", response_model=List[AircraftSchema])
async def public_lookup_aircraft_by_registration(
registration: str,
db: Session = Depends(get_db)
):
"""
Public lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching.
No authentication required.
"""
# Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
if len(clean_input) < 4:
return []
# Query aircraft table using clean_reg column
aircraft_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
return aircraft_list
@router.get("/search", response_model=List[AircraftSchema]) @router.get("/search", response_model=List[AircraftSchema])
async def search_aircraft( async def search_aircraft(
q: Optional[str] = Query(None, description="Search query for registration, type, or manufacturer"), q: Optional[str] = Query(None, description="Search query for registration, type, or manufacturer"),

View File

@@ -68,4 +68,39 @@ async def search_airports(
(Airport.city.ilike("%" + search_term + "%")) (Airport.city.ilike("%" + search_term + "%"))
).limit(limit).all() ).limit(limit).all()
return airports return airports
@router.get("/public/lookup/{code_or_name}", response_model=List[AirportSchema])
async def public_lookup_airport_by_code_or_name(
code_or_name: str,
db: Session = Depends(get_db)
):
"""
Public lookup airport by ICAO code or name.
If input is 4 characters and all uppercase letters, treat as ICAO code.
Otherwise, search by name.
No authentication required.
"""
clean_input = code_or_name.strip().upper()
if len(clean_input) < 2:
return []
# Check if input looks like an ICAO code (4 letters)
if len(clean_input) == 4 and clean_input.isalpha():
# Exact ICAO match first
airport = db.query(Airport).filter(Airport.icao == clean_input).first()
if airport:
return [airport]
# Then search ICAO codes that start with input
airports = db.query(Airport).filter(
Airport.icao.like(clean_input + "%")
).limit(5).all()
return airports
else:
# Search by name (case-insensitive partial match)
airports = db.query(Airport).filter(
Airport.name.ilike("%" + code_or_name + "%")
).limit(10).all()
return airports

View File

@@ -0,0 +1,179 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_arrival import arrival as crud_arrival
from app.schemas.arrival import Arrival, ArrivalCreate, ArrivalUpdate, ArrivalStatus, ArrivalStatusUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Arrival])
async def get_arrivals(
request: Request,
skip: int = 0,
limit: int = 100,
status: Optional[ArrivalStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get arrival records with optional filtering"""
arrivals = crud_arrival.get_multi(
db, skip=skip, limit=limit, status=status,
date_from=date_from, date_to=date_to
)
return arrivals
@router.post("/", response_model=Arrival)
async def create_arrival(
request: Request,
arrival_in: ArrivalCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create a new arrival record"""
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in",
"data": {
"id": arrival.id,
"registration": arrival.registration,
"in_from": arrival.in_from,
"status": arrival.status.value
}
})
return arrival
@router.get("/{arrival_id}", response_model=Arrival)
async def get_arrival(
arrival_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific arrival record"""
arrival = crud_arrival.get(db, arrival_id=arrival_id)
if not arrival:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Arrival record not found"
)
return arrival
@router.put("/{arrival_id}", response_model=Arrival)
async def update_arrival(
request: Request,
arrival_id: int,
arrival_in: ArrivalUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update an arrival record"""
db_arrival = crud_arrival.get(db, arrival_id=arrival_id)
if not db_arrival:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Arrival record not found"
)
# Get user IP from request
user_ip = request.client.host if request.client else None
arrival = crud_arrival.update(
db,
db_obj=db_arrival,
obj_in=arrival_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_updated",
"data": {
"id": arrival.id,
"registration": arrival.registration,
"status": arrival.status.value
}
})
return arrival
@router.patch("/{arrival_id}/status", response_model=Arrival)
async def update_arrival_status(
request: Request,
arrival_id: int,
status_update: ArrivalStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update arrival status"""
client_ip = get_client_ip(request)
arrival = crud_arrival.update_status(
db,
arrival_id=arrival_id,
status=status_update.status,
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not arrival:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Arrival record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_status_update",
"data": {
"id": arrival.id,
"registration": arrival.registration,
"status": arrival.status.value,
"landed_dt": arrival.landed_dt.isoformat() if arrival.landed_dt else None
}
})
return arrival
@router.delete("/{arrival_id}", response_model=Arrival)
async def cancel_arrival(
request: Request,
arrival_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Cancel an arrival record"""
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
if not arrival:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Arrival record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_cancelled",
"data": {
"id": arrival.id,
"registration": arrival.registration
}
})
return arrival

View File

@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_admin_user, get_current_read_user
from app.core.config import settings from app.core.config import settings
from app.core.security import create_access_token from app.core.security import create_access_token
from app.crud.crud_user import user as crud_user from app.crud.crud_user import user as crud_user
from app.schemas.ppr import Token, UserCreate, UserUpdate, User from app.schemas.ppr import Token, UserCreate, UserUpdate, User, ChangePassword
router = APIRouter() router = APIRouter()
@@ -33,7 +33,11 @@ async def login_for_access_token(
subject=user.username, expires_delta=access_token_expires subject=user.username, expires_delta=access_token_expires
) )
return {"access_token": access_token, "token_type": "bearer"} return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": settings.access_token_expire_minutes * 60 # seconds
}
@router.post("/test-token", response_model=User) @router.post("/test-token", response_model=User)
@@ -54,6 +58,22 @@ async def list_users(
return users return users
@router.get("/users/{user_id}", response_model=User)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_admin_user)
):
"""Get a specific user's details (admin only)"""
user = crud_user.get(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.post("/users", response_model=User) @router.post("/users", response_model=User)
async def create_user( async def create_user(
user_in: UserCreate, user_in: UserCreate,
@@ -86,4 +106,22 @@ async def update_user(
detail="User not found" detail="User not found"
) )
user = crud_user.update(db, db_obj=user, obj_in=user_in) user = crud_user.update(db, db_obj=user, obj_in=user_in)
return user
@router.post("/users/{user_id}/change-password", response_model=User)
async def change_user_password(
user_id: int,
password_data: ChangePassword,
db: Session = Depends(get_db),
current_user = Depends(get_current_admin_user)
):
"""Change a user's password (admin only)"""
user = crud_user.get(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
return user return user

View File

@@ -0,0 +1,108 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_circuit import crud_circuit
from app.schemas.circuit import Circuit, CircuitCreate, CircuitUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Circuit])
async def get_circuits(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get circuit records"""
circuits = crud_circuit.get_multi(db, skip=skip, limit=limit)
return circuits
@router.get("/flight/{local_flight_id}", response_model=List[Circuit])
async def get_circuits_by_flight(
local_flight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get all circuits for a specific local flight"""
circuits = crud_circuit.get_by_local_flight(db, local_flight_id=local_flight_id)
return circuits
@router.post("/", response_model=Circuit)
async def create_circuit(
request: Request,
circuit_in: CircuitCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Record a new circuit (touch and go) for a local flight"""
circuit = crud_circuit.create(db, obj_in=circuit_in)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "circuit_recorded",
"data": {
"id": circuit.id,
"local_flight_id": circuit.local_flight_id,
"circuit_timestamp": circuit.circuit_timestamp.isoformat()
}
})
return circuit
@router.get("/{circuit_id}", response_model=Circuit)
async def get_circuit(
circuit_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific circuit record"""
circuit = crud_circuit.get(db, circuit_id=circuit_id)
if not circuit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Circuit record not found"
)
return circuit
@router.put("/{circuit_id}", response_model=Circuit)
async def update_circuit(
circuit_id: int,
circuit_in: CircuitUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update a circuit record"""
circuit = crud_circuit.get(db, circuit_id=circuit_id)
if not circuit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Circuit record not found"
)
circuit = crud_circuit.update(db, db_obj=circuit, obj_in=circuit_in)
return circuit
@router.delete("/{circuit_id}")
async def delete_circuit(
circuit_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Delete a circuit record"""
circuit = crud_circuit.get(db, circuit_id=circuit_id)
if not circuit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Circuit record not found"
)
crud_circuit.delete(db, circuit_id=circuit_id)
return {"detail": "Circuit record deleted"}

View File

@@ -0,0 +1,179 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_departure import departure as crud_departure
from app.schemas.departure import Departure, DepartureCreate, DepartureUpdate, DepartureStatus, DepartureStatusUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Departure])
async def get_departures(
request: Request,
skip: int = 0,
limit: int = 100,
status: Optional[DepartureStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get departure records with optional filtering"""
departures = crud_departure.get_multi(
db, skip=skip, limit=limit, status=status,
date_from=date_from, date_to=date_to
)
return departures
@router.post("/", response_model=Departure)
async def create_departure(
request: Request,
departure_in: DepartureCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create a new departure record"""
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_booked_out",
"data": {
"id": departure.id,
"registration": departure.registration,
"out_to": departure.out_to,
"status": departure.status.value
}
})
return departure
@router.get("/{departure_id}", response_model=Departure)
async def get_departure(
departure_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific departure record"""
departure = crud_departure.get(db, departure_id=departure_id)
if not departure:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Departure record not found"
)
return departure
@router.put("/{departure_id}", response_model=Departure)
async def update_departure(
request: Request,
departure_id: int,
departure_in: DepartureUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update a departure record"""
db_departure = crud_departure.get(db, departure_id=departure_id)
if not db_departure:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Departure record not found"
)
# Get user IP from request
user_ip = request.client.host if request.client else None
departure = crud_departure.update(
db,
db_obj=db_departure,
obj_in=departure_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_updated",
"data": {
"id": departure.id,
"registration": departure.registration,
"status": departure.status.value
}
})
return departure
@router.patch("/{departure_id}/status", response_model=Departure)
async def update_departure_status(
request: Request,
departure_id: int,
status_update: DepartureStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update departure status"""
client_ip = get_client_ip(request)
departure = crud_departure.update_status(
db,
departure_id=departure_id,
status=status_update.status,
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not departure:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Departure record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_status_update",
"data": {
"id": departure.id,
"registration": departure.registration,
"status": departure.status.value,
"departed_dt": departure.departed_dt.isoformat() if departure.departed_dt else None
}
})
return departure
@router.delete("/{departure_id}", response_model=Departure)
async def cancel_departure(
request: Request,
departure_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Cancel a departure record"""
departure = crud_departure.cancel(db, departure_id=departure_id)
if not departure:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Departure record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_cancelled",
"data": {
"id": departure.id,
"registration": departure.registration
}
})
return departure

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api import deps
from app.crud.crud_journal import journal
from app.models.journal import EntityType
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
from typing import List
router = APIRouter(tags=["journal"])
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
async def get_entity_journal(
entity_type: str,
entity_id: int,
limit: int = 100,
db: Session = Depends(deps.get_db),
current_user = Depends(deps.get_current_user)
):
"""
Get journal entries for a specific entity (PPR, LOCAL_FLIGHT, ARRIVAL, or DEPARTURE).
The journal is immutable - entries are created automatically by the backend
when changes are made. This endpoint is read-only.
Parameters:
- entity_type: One of 'PPR', 'LOCAL_FLIGHT', 'ARRIVAL', 'DEPARTURE'
- entity_id: The ID of the entity
- limit: Maximum number of entries to return (default 100)
"""
# Validate entity type
try:
entity = EntityType[entity_type.upper()]
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}"
)
entries = journal.get_entity_journal(db, entity, entity_id, limit=limit)
return EntityJournalResponse(
entity_type=entity_type,
entity_id=entity_id,
entries=entries,
total_entries=len(entries)
)
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
async def get_user_journal(
username: str,
limit: int = 100,
db: Session = Depends(deps.get_db),
current_user = Depends(deps.get_current_user)
):
"""
Get all journal entries created by a specific user.
This endpoint is read-only and returns entries in reverse chronological order.
"""
entries = journal.get_user_journal(db, username, limit=limit)
return entries

View File

@@ -0,0 +1,207 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.schemas.local_flight import LocalFlight, LocalFlightCreate, LocalFlightUpdate, LocalFlightStatus, LocalFlightType, LocalFlightStatusUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[LocalFlight])
async def get_local_flights(
request: Request,
skip: int = 0,
limit: int = 100,
status: Optional[LocalFlightStatus] = None,
flight_type: Optional[LocalFlightType] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get local flight records with optional filtering"""
flights = crud_local_flight.get_multi(
db, skip=skip, limit=limit, status=status,
flight_type=flight_type, date_from=date_from, date_to=date_to
)
return flights
@router.post("/", response_model=LocalFlight)
async def create_local_flight(
request: Request,
flight_in: LocalFlightCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create a new local flight record (book out)"""
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "local_flight_booked_out",
"data": {
"id": flight.id,
"registration": flight.registration,
"flight_type": flight.flight_type.value,
"status": flight.status.value
}
})
return flight
@router.get("/{flight_id}", response_model=LocalFlight)
async def get_local_flight(
flight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific local flight record"""
flight = crud_local_flight.get(db, flight_id=flight_id)
if not flight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local flight record not found"
)
return flight
@router.put("/{flight_id}", response_model=LocalFlight)
async def update_local_flight(
request: Request,
flight_id: int,
flight_in: LocalFlightUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update a local flight record"""
db_flight = crud_local_flight.get(db, flight_id=flight_id)
if not db_flight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local flight record not found"
)
# Get user IP from request
user_ip = request.client.host if request.client else None
flight = crud_local_flight.update(
db,
db_obj=db_flight,
obj_in=flight_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "local_flight_updated",
"data": {
"id": flight.id,
"registration": flight.registration,
"status": flight.status.value
}
})
return flight
@router.patch("/{flight_id}/status", response_model=LocalFlight)
async def update_local_flight_status(
request: Request,
flight_id: int,
status_update: LocalFlightStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update local flight status (LANDED, CANCELLED, etc.)"""
client_ip = get_client_ip(request)
flight = crud_local_flight.update_status(
db,
flight_id=flight_id,
status=status_update.status,
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not flight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local flight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "local_flight_status_update",
"data": {
"id": flight.id,
"registration": flight.registration,
"status": flight.status.value,
"landed_dt": flight.landed_dt.isoformat() if flight.landed_dt else None
}
})
return flight
@router.delete("/{flight_id}", response_model=LocalFlight)
async def cancel_local_flight(
request: Request,
flight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Cancel a local flight record"""
flight = crud_local_flight.cancel(db, flight_id=flight_id)
if not flight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local flight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "local_flight_cancelled",
"data": {
"id": flight.id,
"registration": flight.registration
}
})
return flight
@router.get("/active/current", response_model=List[LocalFlight])
async def get_active_flights(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get currently active (booked out) flights"""
return crud_local_flight.get_active_flights(db)
@router.get("/today/departures", response_model=List[LocalFlight])
async def get_today_departures(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get today's departures (booked out or departed)"""
return crud_local_flight.get_departures_today(db)
@router.get("/today/booked-out", response_model=List[LocalFlight])
async def get_today_booked_out(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get all flights booked out today"""
return crud_local_flight.get_booked_out_today(db)

View File

@@ -0,0 +1,206 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_overflight import overflight as crud_overflight
from app.schemas.overflight import Overflight, OverflightCreate, OverflightUpdate, OverflightStatus, OverflightStatusUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Overflight])
async def get_overflights(
request: Request,
skip: int = 0,
limit: int = 100,
status: Optional[OverflightStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get overflight records with optional filtering"""
overflights = crud_overflight.get_multi(
db, skip=skip, limit=limit, status=status,
date_from=date_from, date_to=date_to
)
return overflights
@router.post("/", response_model=Overflight)
async def create_overflight(
request: Request,
overflight_in: OverflightCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create a new overflight record"""
overflight = crud_overflight.create(db, obj_in=overflight_in, created_by=current_user.username)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_created",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"departure_airfield": overflight.departure_airfield,
"destination_airfield": overflight.destination_airfield,
"status": overflight.status.value
}
})
return overflight
@router.get("/{overflight_id}", response_model=Overflight)
async def get_overflight(
overflight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific overflight record"""
overflight = crud_overflight.get(db, overflight_id=overflight_id)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
return overflight
@router.put("/{overflight_id}", response_model=Overflight)
async def update_overflight(
request: Request,
overflight_id: int,
overflight_in: OverflightUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update an overflight record"""
db_overflight = crud_overflight.get(db, overflight_id=overflight_id)
if not db_overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Get user IP from request
user_ip = request.client.host if request.client else None
overflight = crud_overflight.update(
db,
db_obj=db_overflight,
obj_in=overflight_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_updated",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"status": overflight.status.value
}
})
return overflight
@router.patch("/{overflight_id}/status", response_model=Overflight)
async def update_overflight_status(
request: Request,
overflight_id: int,
status_update: OverflightStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update overflight status (ACTIVE -> INACTIVE for QSY)"""
client_ip = get_client_ip(request)
overflight = crud_overflight.update_status(
db,
overflight_id=overflight_id,
status=status_update.status,
timestamp=status_update.qsy_dt,
user=current_user.username,
user_ip=client_ip
)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_status_update",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"status": overflight.status.value,
"qsy_dt": overflight.qsy_dt.isoformat() if overflight.qsy_dt else None
}
})
return overflight
@router.delete("/{overflight_id}", response_model=Overflight)
async def cancel_overflight(
request: Request,
overflight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Cancel an overflight record"""
client_ip = get_client_ip(request)
overflight = crud_overflight.cancel(
db,
overflight_id=overflight_id,
user=current_user.username,
user_ip=client_ip
)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_cancelled",
"data": {
"id": overflight.id,
"registration": overflight.registration
}
})
return overflight
@router.get("/active/list", response_model=List[Overflight])
async def get_active_overflights(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get currently active overflights"""
overflights = crud_overflight.get_active_overflights(db)
return overflights
@router.get("/today/list", response_model=List[Overflight])
async def get_overflights_today(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get today's overflights"""
overflights = crud_overflight.get_overflights_today(db)
return overflights

View File

@@ -8,6 +8,8 @@ from app.crud.crud_journal import journal as crud_journal
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
from app.models.ppr import User from app.models.ppr import User
from app.core.utils import get_client_ip from app.core.utils import get_client_ip
from app.core.email import email_service
from app.core.config import settings
router = APIRouter() router = APIRouter()
@@ -56,6 +58,48 @@ async def create_ppr(
return ppr return ppr
@router.post("/public", response_model=PPR)
async def create_public_ppr(
request: Request,
ppr_in: PPRCreate,
db: Session = Depends(get_db)
):
"""Create a new PPR record (public endpoint, no authentication required)"""
client_ip = get_client_ip(request)
# For public submissions, use a default created_by or None
ppr = crud_ppr.create(db, obj_in=ppr_in, created_by="public", user_ip=client_ip)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "ppr_created",
"data": {
"id": ppr.id,
"ac_reg": ppr.ac_reg,
"status": ppr.status.value
}
})
# Send email if email provided
if ppr_in.email:
await email_service.send_email(
to_email=ppr_in.email,
subject="PPR Submitted Successfully",
template_name="ppr_submitted.html",
template_vars={
"name": ppr_in.captain,
"aircraft": ppr_in.ac_reg,
"arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A",
"purpose": ppr_in.notes or "N/A",
"public_token": ppr.public_token,
"base_url": settings.base_url
}
)
return ppr
@router.get("/{ppr_id}", response_model=PPR) @router.get("/{ppr_id}", response_model=PPR)
async def get_ppr( async def get_ppr(
ppr_id: int, ppr_id: int,
@@ -174,6 +218,20 @@ async def update_ppr_status(
} }
}) })
# Send email if cancelled and email provided
if status_update.status == PPRStatus.CANCELED and ppr.email:
await email_service.send_email(
to_email=ppr.email,
subject="PPR Cancelled",
template_name="ppr_cancelled.html",
template_vars={
"name": ppr.captain,
"aircraft": ppr.ac_reg,
"arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A"
}
)
return ppr return ppr
@@ -206,6 +264,100 @@ async def delete_ppr(
return ppr return ppr
@router.get("/public/edit/{token}", response_model=PPR)
async def get_ppr_for_edit(
token: str,
db: Session = Depends(get_db)
):
"""Get PPR details for public editing using token"""
ppr = crud_ppr.get_by_public_token(db, token)
if not ppr:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired token"
)
# Only allow editing if not already processed
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be edited at this stage"
)
return ppr
@router.patch("/public/edit/{token}", response_model=PPR)
async def update_ppr_public(
token: str,
ppr_in: PPRUpdate,
request: Request,
db: Session = Depends(get_db)
):
"""Update PPR publicly using token"""
ppr = crud_ppr.get_by_public_token(db, token)
if not ppr:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired token"
)
# Only allow editing if not already processed
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be edited at this stage"
)
client_ip = get_client_ip(request)
updated_ppr = crud_ppr.update(db, db_obj=ppr, obj_in=ppr_in, user="public", user_ip=client_ip)
return updated_ppr
@router.delete("/public/cancel/{token}", response_model=PPR)
async def cancel_ppr_public(
token: str,
request: Request,
db: Session = Depends(get_db)
):
"""Cancel PPR publicly using token"""
ppr = crud_ppr.get_by_public_token(db, token)
if not ppr:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired token"
)
# Only allow canceling if not already processed
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be cancelled at this stage"
)
client_ip = get_client_ip(request)
# Cancel by setting status to CANCELED
cancelled_ppr = crud_ppr.update_status(
db,
ppr_id=ppr.id,
status=PPRStatus.CANCELED,
user="public",
user_ip=client_ip
)
# Send cancellation email if email provided
if cancelled_ppr.email:
await email_service.send_email(
to_email=cancelled_ppr.email,
subject="PPR Cancelled",
template_name="ppr_cancelled.html",
template_vars={
"name": cancelled_ppr.captain,
"aircraft": cancelled_ppr.ac_reg,
"arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A"
}
)
return cancelled_ppr
@router.get("/{ppr_id}/journal", response_model=List[Journal]) @router.get("/{ppr_id}/journal", response_model=List[Journal])
async def get_ppr_journal( async def get_ppr_journal(
ppr_id: int, ppr_id: int,

View File

@@ -3,20 +3,240 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.crud.crud_ppr import ppr as crud_ppr from app.crud.crud_ppr import ppr as crud_ppr
from app.schemas.ppr import PPR from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.crud.crud_departure import departure as crud_departure
from app.crud.crud_arrival import arrival as crud_arrival
from app.schemas.ppr import PPRPublic
from app.models.local_flight import LocalFlightStatus
from app.models.departure import DepartureStatus
from app.models.arrival import ArrivalStatus
from datetime import date, datetime, timedelta
import re
router = APIRouter() router = APIRouter()
@router.get("/arrivals", response_model=List[PPR]) def lighten_color(hex_color, factor=0.3):
"""Lighten a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = min(255, int(r + (255 - r) * factor))
g = min(255, int(g + (255 - g) * factor))
b = min(255, int(b + (255 - b) * factor))
return f"#{r:02x}{g:02x}{b:02x}"
def darken_color(hex_color, factor=0.3):
"""Darken a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = max(0, int(r * (1 - factor)))
g = max(0, int(g * (1 - factor)))
b = max(0, int(b * (1 - factor)))
return f"#{r:02x}{g:02x}{b:02x}"
@router.get("/arrivals")
async def get_public_arrivals(db: Session = Depends(get_db)): async def get_public_arrivals(db: Session = Depends(get_db)):
"""Get today's arrivals for public display""" """Get today's arrivals for public display (PPR and local flights)"""
arrivals = crud_ppr.get_arrivals_today(db) arrivals = crud_ppr.get_arrivals_today(db)
return arrivals
# Convert PPR arrivals to dictionaries
arrivals_list = []
for arrival in arrivals:
arrivals_list.append({
'ac_call': arrival.ac_call,
'ac_reg': arrival.ac_reg,
'ac_type': arrival.ac_type,
'in_from': arrival.in_from,
'eta': arrival.eta,
'landed_dt': arrival.landed_dt,
'status': arrival.status.value,
'isLocalFlight': False
})
# Add local flights with DEPARTED status that were booked out today
local_flights = crud_local_flight.get_multi(
db,
status=LocalFlightStatus.DEPARTED,
limit=1000
)
# Get today's date boundaries
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
# Convert local flights to match the PPR format for display
for flight in local_flights:
# Only include flights booked out today
if not (today_start <= flight.created_dt < today_end):
continue
# Calculate ETA from departed_dt + duration (if both are available)
eta = flight.departed_dt
if flight.departed_dt and flight.duration:
eta = flight.departed_dt + timedelta(minutes=flight.duration)
arrivals_list.append({
'ac_call': flight.callsign or flight.registration,
'ac_reg': flight.registration,
'ac_type': flight.type,
'in_from': None,
'eta': eta,
'landed_dt': None,
'status': 'DEPARTED',
'isLocalFlight': True,
'flight_type': flight.flight_type.value
})
# Add booked-in arrivals
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
for arrival in booked_in_arrivals:
# Only include BOOKED_IN and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
continue
# For BOOKED_IN, only include those created today
if arrival.status == ArrivalStatus.BOOKED_IN:
if not (today_start <= arrival.created_dt < today_end):
continue
# For LANDED, only include those landed today
elif arrival.status == ArrivalStatus.LANDED:
if not arrival.landed_dt or not (today_start <= arrival.landed_dt < today_end):
continue
arrivals_list.append({
'registration': arrival.registration,
'callsign': arrival.callsign,
'type': arrival.type,
'in_from': arrival.in_from,
'eta': arrival.eta,
'landed_dt': arrival.landed_dt,
'status': arrival.status.value,
'isBookedIn': True
})
return arrivals_list
@router.get("/departures", response_model=List[PPR]) @router.get("/departures")
async def get_public_departures(db: Session = Depends(get_db)): async def get_public_departures(db: Session = Depends(get_db)):
"""Get today's departures for public display""" """Get today's departures for public display (PPR, local flights, and departures to other airports)"""
departures = crud_ppr.get_departures_today(db) departures = crud_ppr.get_departures_today(db)
return departures
# Convert PPR departures to dictionaries
departures_list = []
for departure in departures:
departures_list.append({
'ac_call': departure.ac_call,
'ac_reg': departure.ac_reg,
'ac_type': departure.ac_type,
'out_to': departure.out_to,
'etd': departure.etd,
'departed_dt': departure.departed_dt,
'status': departure.status.value,
'isLocalFlight': False,
'isDeparture': False
})
# Add local flights with BOOKED_OUT status that were booked out today
local_flights = crud_local_flight.get_multi(
db,
status=LocalFlightStatus.BOOKED_OUT,
limit=1000
)
# Get today's date boundaries
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
# Convert local flights to match the PPR format for display
for flight in local_flights:
# Only include flights booked out today
if not (today_start <= flight.created_dt < today_end):
continue
departures_list.append({
'ac_call': flight.callsign or flight.registration,
'ac_reg': flight.registration,
'ac_type': flight.type,
'out_to': None,
'etd': flight.etd or flight.created_dt,
'departed_dt': None,
'status': 'BOOKED_OUT',
'isLocalFlight': True,
'flight_type': flight.flight_type.value,
'isDeparture': False
})
# Add departures to other airports with BOOKED_OUT status
departures_to_airports = crud_departure.get_multi(
db,
status=DepartureStatus.BOOKED_OUT,
limit=1000
)
# Get today's date boundaries
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
# Convert departures to match the format for display
for dep in departures_to_airports:
# Only include departures booked out today
if not (today_start <= dep.created_dt < today_end):
continue
departures_list.append({
'ac_call': dep.callsign or dep.registration,
'ac_reg': dep.registration,
'ac_type': dep.type,
'out_to': dep.out_to,
'etd': dep.etd or dep.created_dt,
'departed_dt': None,
'status': 'BOOKED_OUT',
'isLocalFlight': False,
'isDeparture': True
})
# Add departures to other airports with DEPARTED status (taken off today)
departed_to_airports = crud_departure.get_multi(
db,
status=DepartureStatus.DEPARTED,
limit=1000
)
for dep in departed_to_airports:
# Only include departures that departed today
if not dep.departed_dt or not (today_start <= dep.departed_dt < today_end):
continue
departures_list.append({
'ac_call': dep.callsign or dep.registration,
'ac_reg': dep.registration,
'ac_type': dep.type,
'out_to': dep.out_to,
'etd': dep.etd or dep.created_dt,
'departed_dt': dep.departed_dt,
'status': 'DEPARTED',
'isLocalFlight': False,
'isDeparture': True
})
return departures_list
@router.get("/config")
async def get_ui_config():
"""Get UI configuration for client-side rendering"""
from app.core.config import settings
base_color = settings.top_bar_base_color
return {
"tag": settings.tag,
"top_bar_gradient_start": base_color,
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
"footer_color": darken_color(base_color, 0.2), # Darken for footer
"environment": settings.environment
}

View File

@@ -5,28 +5,33 @@ from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
# Database settings # Database settings
db_host: str = "db" # Docker service name db_host: str = "db" # Docker service name
db_user: str = "ppr_user" db_user: str
db_password: str = "ppr_password123" db_password: str
db_name: str = "ppr_nextgen" db_name: str
db_port: int = 3306 db_port: int = 3306
# Security settings # Security settings
secret_key: str = "your-secret-key-change-this-in-production" secret_key: str
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 30 access_token_expire_minutes: int = 30
# Mail settings # Mail settings
mail_host: str = "send.one.com" mail_host: str
mail_port: int = 465 mail_port: int = 465
mail_username: str = "noreply@swansea-airport.wales" mail_username: str
mail_password: str = "SASAGoForward2155" mail_password: str
mail_from: str = "noreply@swansea-airport.wales" mail_from: str
mail_from_name: str = "Swansea Airport" mail_from_name: str
# Application settings # Application settings
api_v1_str: str = "/api/v1" api_v1_str: str = "/api/v1"
project_name: str = "Airfield PPR API" project_name: str = "Airfield PPR API"
base_url: str = "https://pprdev.swansea-airport.wales" base_url: str
# UI Configuration
tag: str = ""
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Redis settings (for future use) # Redis settings (for future use)
redis_url: Optional[str] = None redis_url: Optional[str] = None

48
backend/app/core/email.py Normal file
View File

@@ -0,0 +1,48 @@
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, FileSystemLoader
import os
from app.core.config import settings
class EmailService:
def __init__(self):
self.smtp_host = settings.mail_host
self.smtp_port = settings.mail_port
self.smtp_user = settings.mail_username
self.smtp_password = settings.mail_password
self.from_email = settings.mail_from
self.from_name = settings.mail_from_name
# Set up Jinja2 environment for templates
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict):
# Render the template
template = self.jinja_env.get_template(template_name)
html_content = template.render(**template_vars)
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{self.from_name} <{self.from_email}>"
msg['To'] = to_email
# Attach HTML content
html_part = MIMEText(html_content, 'html')
msg.attach(html_part)
# Send email
try:
async with aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True) as smtp:
await smtp.login(self.smtp_user, self.smtp_password)
await smtp.send_message(msg)
except Exception as e:
# Log error, but for now, print
print(f"Failed to send email: {e}")
# In production, use logging
email_service = EmailService()

View File

@@ -0,0 +1,147 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.arrival import Arrival, ArrivalStatus
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDArrival:
def get(self, db: Session, arrival_id: int) -> Optional[Arrival]:
return db.query(Arrival).filter(Arrival.id == arrival_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[ArrivalStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Arrival]:
query = db.query(Arrival)
if status:
query = query.filter(Arrival.status == status)
if date_from:
query = query.filter(func.date(Arrival.created_dt) >= date_from)
if date_to:
query = query.filter(func.date(Arrival.created_dt) <= date_to)
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[Arrival]:
"""Get today's arrivals (booked in or landed)"""
today = date.today()
return db.query(Arrival).filter(
and_(
func.date(Arrival.created_dt) == today,
or_(
Arrival.status == ArrivalStatus.BOOKED_IN,
Arrival.status == ArrivalStatus.LANDED
)
)
).order_by(Arrival.created_dt).all()
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
db_obj = Arrival(
**obj_in.dict(),
created_by=created_by,
status=ArrivalStatus.BOOKED_IN
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.ARRIVAL,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
self,
db: Session,
arrival_id: int,
status: ArrivalStatus,
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp:
db_obj.landed_dt = timestamp
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.ARRIVAL,
arrival_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
db_obj.status = ArrivalStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
arrival = CRUDArrival()

View File

@@ -0,0 +1,55 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime
from app.models.circuit import Circuit
from app.schemas.circuit import CircuitCreate, CircuitUpdate
class CRUDCircuit:
def get(self, db: Session, circuit_id: int) -> Optional[Circuit]:
return db.query(Circuit).filter(Circuit.id == circuit_id).first()
def get_by_local_flight(self, db: Session, local_flight_id: int) -> List[Circuit]:
"""Get all circuits for a specific local flight"""
return db.query(Circuit).filter(
Circuit.local_flight_id == local_flight_id
).order_by(Circuit.circuit_timestamp).all()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100
) -> List[Circuit]:
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all()
def create(self, db: Session, obj_in: CircuitCreate) -> Circuit:
db_obj = Circuit(
local_flight_id=obj_in.local_flight_id,
circuit_timestamp=obj_in.circuit_timestamp
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
obj_data = obj_in.dict(exclude_unset=True)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, circuit_id: int) -> bool:
circuit = self.get(db, circuit_id)
if circuit:
db.delete(circuit)
db.commit()
return True
return False
crud_circuit = CRUDCircuit()

View File

@@ -0,0 +1,147 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.departure import Departure, DepartureStatus
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDDeparture:
def get(self, db: Session, departure_id: int) -> Optional[Departure]:
return db.query(Departure).filter(Departure.id == departure_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[DepartureStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Departure]:
query = db.query(Departure)
if status:
query = query.filter(Departure.status == status)
if date_from:
query = query.filter(func.date(Departure.created_dt) >= date_from)
if date_to:
query = query.filter(func.date(Departure.created_dt) <= date_to)
return query.order_by(desc(Departure.created_dt)).offset(skip).limit(limit).all()
def get_departures_today(self, db: Session) -> List[Departure]:
"""Get today's departures (booked out or departed)"""
today = date.today()
return db.query(Departure).filter(
and_(
func.date(Departure.created_dt) == today,
or_(
Departure.status == DepartureStatus.BOOKED_OUT,
Departure.status == DepartureStatus.DEPARTED
)
)
).order_by(Departure.created_dt).all()
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
db_obj = Departure(
**obj_in.dict(),
created_by=created_by,
status=DepartureStatus.BOOKED_OUT
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.DEPARTURE,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
self,
db: Session,
departure_id: int,
status: DepartureStatus,
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp:
db_obj.departed_dt = timestamp
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.DEPARTURE,
departure_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
db_obj.status = DepartureStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
departure = CRUDDeparture()

View File

@@ -1,35 +1,95 @@
from typing import List from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ppr import Journal from app.models.journal import JournalEntry, EntityType
from app.schemas.ppr import JournalCreate from datetime import datetime
class CRUDJournal: class CRUDJournal:
def create(self, db: Session, obj_in: JournalCreate) -> Journal: """CRUD operations for the generic journal table.
db_obj = Journal(**obj_in.dict())
db.add(db_obj) This journal is immutable - entries can only be created (by backend) and queried.
db.commit() There are no API endpoints for creating journal entries; the backend logs changes directly.
db.refresh(db_obj) """
return db_obj
def get_by_ppr_id(self, db: Session, ppr_id: int) -> List[Journal]:
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
def log_change( def log_change(
self, self,
db: Session, db: Session,
ppr_id: int, entity_type: EntityType,
entity_id: int,
entry: str, entry: str,
user: str, user: str,
ip: str ip: Optional[str] = None
) -> Journal: ) -> JournalEntry:
journal_in = JournalCreate( """Log a change to an entity. Internal backend use only."""
ppr_id=ppr_id, journal_entry = JournalEntry(
entity_type=entity_type.value,
entity_id=entity_id,
entry=entry,
user=user,
ip=ip,
entry_dt=datetime.utcnow()
)
db.add(journal_entry)
db.commit()
db.refresh(journal_entry)
return journal_entry
def get_entity_journal(
self,
db: Session,
entity_type: EntityType,
entity_id: int,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries for a specific entity. Read-only API endpoint."""
return db.query(JournalEntry).filter(
JournalEntry.entity_type == entity_type.value,
JournalEntry.entity_id == entity_id
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
def get_user_journal(
self,
db: Session,
user: str,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries created by a specific user."""
return db.query(JournalEntry).filter(
JournalEntry.user == user
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
# Convenience methods for backward compatibility with PPR journal
def log_ppr_change(
self,
db: Session,
ppr_id: int,
entry: str,
user: str,
ip: Optional[str] = None
) -> JournalEntry:
"""Log a change to a PPR (convenience method)."""
return self.log_change(
db=db,
entity_type=EntityType.PPR,
entity_id=ppr_id,
entry=entry, entry=entry,
user=user, user=user,
ip=ip ip=ip
) )
return self.create(db, journal_in)
def get_ppr_journal(
self,
db: Session,
ppr_id: int,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries for a PPR (convenience method)."""
return self.get_entity_journal(
db=db,
entity_type=EntityType.PPR,
entity_id=ppr_id,
limit=limit
)
journal = CRUDJournal() journal = CRUDJournal()

View File

@@ -0,0 +1,185 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
from app.models.journal import EntityType
from app.models.circuit import Circuit
from app.crud.crud_journal import journal
class CRUDLocalFlight:
def get(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
return db.query(LocalFlight).filter(LocalFlight.id == flight_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[LocalFlightStatus] = None,
flight_type: Optional[LocalFlightType] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[LocalFlight]:
query = db.query(LocalFlight)
if status:
query = query.filter(LocalFlight.status == status)
if flight_type:
query = query.filter(LocalFlight.flight_type == flight_type)
if date_from:
query = query.filter(func.date(LocalFlight.created_dt) >= date_from)
if date_to:
query = query.filter(func.date(LocalFlight.created_dt) <= date_to)
return query.order_by(desc(LocalFlight.created_dt)).offset(skip).limit(limit).all()
def get_active_flights(self, db: Session) -> List[LocalFlight]:
"""Get currently active (booked out or departed) flights"""
return db.query(LocalFlight).filter(
or_(
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
LocalFlight.status == LocalFlightStatus.DEPARTED
)
).order_by(desc(LocalFlight.created_dt)).all()
def get_departures_today(self, db: Session) -> List[LocalFlight]:
"""Get today's departures (booked out or departed)"""
today = date.today()
return db.query(LocalFlight).filter(
and_(
func.date(LocalFlight.created_dt) == today,
or_(
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
LocalFlight.status == LocalFlightStatus.DEPARTED
)
)
).order_by(LocalFlight.created_dt).all()
def get_booked_out_today(self, db: Session) -> List[LocalFlight]:
"""Get all flights booked out today"""
today = date.today()
return db.query(LocalFlight).filter(
and_(
func.date(LocalFlight.created_dt) == today,
or_(
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
LocalFlight.status == LocalFlightStatus.LANDED
)
)
).order_by(LocalFlight.created_dt).all()
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
db_obj = LocalFlight(
**obj_in.dict(),
created_by=created_by,
status=LocalFlightStatus.BOOKED_OUT
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
self,
db: Session,
flight_id: int,
status: LocalFlightStatus,
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[LocalFlight]:
db_obj = self.get(db, flight_id)
if not db_obj:
return None
# Ensure status is a LocalFlightStatus enum
if isinstance(status, str):
status = LocalFlightStatus(status)
old_status = db_obj.status
db_obj.status = status
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == LocalFlightStatus.DEPARTED:
db_obj.departed_dt = current_time
elif status == LocalFlightStatus.LANDED:
db_obj.landed_dt = current_time
# Count circuits from the circuits table and populate the circuits column
circuit_count = db.query(func.count(Circuit.id)).filter(
Circuit.local_flight_id == flight_id
).scalar()
db_obj.circuits = circuit_count
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
flight_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
db_obj = self.get(db, flight_id)
if db_obj:
db_obj.status = LocalFlightStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
local_flight = CRUDLocalFlight()

View File

@@ -0,0 +1,172 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.overflight import Overflight, OverflightStatus
from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDOverflight:
def get(self, db: Session, overflight_id: int) -> Optional[Overflight]:
return db.query(Overflight).filter(Overflight.id == overflight_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[OverflightStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Overflight]:
query = db.query(Overflight)
if status:
query = query.filter(Overflight.status == status)
if date_from:
query = query.filter(func.date(Overflight.created_dt) >= date_from)
if date_to:
query = query.filter(func.date(Overflight.created_dt) <= date_to)
return query.order_by(desc(Overflight.created_dt)).offset(skip).limit(limit).all()
def get_active_overflights(self, db: Session) -> List[Overflight]:
"""Get currently active overflights"""
return db.query(Overflight).filter(
Overflight.status == OverflightStatus.ACTIVE
).order_by(desc(Overflight.created_dt)).all()
def get_overflights_today(self, db: Session) -> List[Overflight]:
"""Get today's overflights"""
today = date.today()
return db.query(Overflight).filter(
func.date(Overflight.created_dt) == today
).order_by(Overflight.created_dt).all()
def create(self, db: Session, obj_in: OverflightCreate, created_by: str) -> Overflight:
db_obj = Overflight(
**obj_in.dict(),
created_by=created_by,
status=OverflightStatus.ACTIVE
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log creation in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
db_obj.id,
f"Overflight created: {obj_in.registration} from {obj_in.departure_airfield} to {obj_in.destination_airfield}",
created_by,
None
)
return db_obj
def update(self, db: Session, db_obj: Overflight, obj_in: OverflightUpdate, user: str = "system", user_ip: Optional[str] = None) -> Overflight:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.OVERFLIGHT,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
self,
db: Session,
overflight_id: int,
status: OverflightStatus,
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Overflight]:
db_obj = self.get(db, overflight_id)
if not db_obj:
return None
# Ensure status is an OverflightStatus enum
if isinstance(status, str):
status = OverflightStatus(status)
old_status = db_obj.status
db_obj.status = status
# Set timestamp if transitioning to INACTIVE (QSY'd)
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == OverflightStatus.INACTIVE:
db_obj.qsy_dt = current_time
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
overflight_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, overflight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Overflight]:
db_obj = self.get(db, overflight_id)
if db_obj:
old_status = db_obj.status
db_obj.status = OverflightStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log cancellation in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
overflight_id,
f"Status changed from {old_status.value} to CANCELLED",
user,
user_ip
)
return db_obj
overflight = CRUDOverflight()

View File

@@ -2,7 +2,9 @@ from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime from datetime import date, datetime
import secrets
from app.models.ppr import PPRRecord, PPRStatus from app.models.ppr import PPRRecord, PPRStatus
from app.models.journal import EntityType
from app.schemas.ppr import PPRCreate, PPRUpdate from app.schemas.ppr import PPRCreate, PPRUpdate
from app.crud.crud_journal import journal as crud_journal from app.crud.crud_journal import journal as crud_journal
@@ -11,6 +13,9 @@ class CRUDPPR:
def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]: def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]:
return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first() return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first()
def get_by_public_token(self, db: Session, token: str) -> Optional[PPRRecord]:
return db.query(PPRRecord).filter(PPRRecord.public_token == token).first()
def get_multi( def get_multi(
self, self,
db: Session, db: Session,
@@ -44,12 +49,17 @@ class CRUDPPR:
return query.order_by(desc(PPRRecord.submitted_dt)).offset(skip).limit(limit).all() return query.order_by(desc(PPRRecord.submitted_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[PPRRecord]: def get_arrivals_today(self, db: Session) -> List[PPRRecord]:
"""Get today's arrivals""" """Get today's arrivals - includes aircraft that have arrived and may have departed"""
today = date.today() today = date.today()
return db.query(PPRRecord).filter( return db.query(PPRRecord).filter(
and_( and_(
func.date(PPRRecord.eta) == today, func.date(PPRRecord.eta) == today,
PPRRecord.status == PPRStatus.NEW or_(
PPRRecord.status == PPRStatus.NEW,
PPRRecord.status == PPRStatus.CONFIRMED,
PPRRecord.status == PPRStatus.LANDED,
PPRRecord.status == PPRStatus.DEPARTED
)
) )
).order_by(PPRRecord.eta).all() ).order_by(PPRRecord.eta).all()
@@ -59,7 +69,10 @@ class CRUDPPR:
return db.query(PPRRecord).filter( return db.query(PPRRecord).filter(
and_( and_(
func.date(PPRRecord.etd) == today, func.date(PPRRecord.etd) == today,
PPRRecord.status == PPRStatus.LANDED or_(
PPRRecord.status == PPRStatus.LANDED,
PPRRecord.status == PPRStatus.DEPARTED
)
) )
).order_by(PPRRecord.etd).all() ).order_by(PPRRecord.etd).all()
@@ -67,7 +80,8 @@ class CRUDPPR:
db_obj = PPRRecord( db_obj = PPRRecord(
**obj_in.dict(), **obj_in.dict(),
created_by=created_by, created_by=created_by,
status=PPRStatus.NEW status=PPRStatus.NEW,
public_token=secrets.token_urlsafe(64)
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
@@ -76,6 +90,7 @@ class CRUDPPR:
# Log creation in journal # Log creation in journal
crud_journal.log_change( crud_journal.log_change(
db, db,
EntityType.PPR,
db_obj.id, db_obj.id,
f"PPR created for {db_obj.ac_reg}", f"PPR created for {db_obj.ac_reg}",
created_by, created_by,
@@ -85,11 +100,22 @@ class CRUDPPR:
return db_obj return db_obj
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord: def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True) update_data = obj_in.dict(exclude_unset=True)
changes = [] changes = []
for field, value in update_data.items(): for field, value in update_data.items():
old_value = getattr(db_obj, field) old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value: if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'") changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value) setattr(db_obj, field, value)
@@ -101,7 +127,7 @@ class CRUDPPR:
# Log changes in journal # Log changes in journal
for change in changes: for change in changes:
crud_journal.log_change(db, db_obj.id, change, user, user_ip) crud_journal.log_ppr_change(db, db_obj.id, change, user, user_ip)
return db_obj return db_obj
@@ -133,7 +159,7 @@ class CRUDPPR:
db.refresh(db_obj) db.refresh(db_obj)
# Log status change in journal # Log status change in journal
crud_journal.log_change( crud_journal.log_ppr_change(
db, db,
db_obj.id, db_obj.id,
f"Status changed from {old_status.value} to {status.value}", f"Status changed from {old_status.value} to {status.value}",

View File

@@ -50,5 +50,14 @@ class CRUDUser:
# For future use if we add user status # For future use if we add user status
return True return True
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
"""Change a user's password (typically used by admins to reset another user's password)"""
hashed_password = get_password_hash(new_password)
db_obj.password = hashed_password
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
user = CRUDUser() user = CRUDUser()

View File

@@ -2,9 +2,27 @@ from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconn
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from typing import List from typing import List
import json import json
import logging
import asyncio
import redis.asyncio as redis
from app.core.config import settings from app.core.config import settings
from app.api.api import api_router from app.api.api import api_router
# Import models to ensure they're registered with SQLAlchemy
from app.models.ppr import PPRRecord, User, Airport, Aircraft
from app.models.journal import JournalEntry
from app.models.local_flight import LocalFlight
from app.models.departure import Departure
from app.models.arrival import Arrival
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Redis client for pub/sub (cross-worker communication)
redis_client = None
pubsub = None
app = FastAPI( app = FastAPI(
title=settings.project_name, title=settings.project_name,
openapi_url=f"{settings.api_v1_str}/openapi.json", openapi_url=f"{settings.api_v1_str}/openapi.json",
@@ -25,28 +43,117 @@ app.add_middleware(
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
self.active_connections: List[WebSocket] = [] self.active_connections: List[WebSocket] = []
self.redis_listener_task = None
async def connect(self, websocket: WebSocket): async def connect(self, websocket: WebSocket):
await websocket.accept() await websocket.accept()
self.active_connections.append(websocket) self.active_connections.append(websocket)
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket): def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket) if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: str, websocket: WebSocket): async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message) await websocket.send_text(message)
async def broadcast(self, message: dict): async def broadcast_local(self, message_str: str):
message_str = json.dumps(message) """Broadcast to connections on this worker only"""
dead_connections = []
for connection in self.active_connections: for connection in self.active_connections:
try: try:
await connection.send_text(message_str) await connection.send_text(message_str)
except: except Exception as e:
# Connection is dead, remove it logger.warning(f"Failed to send to connection: {e}")
dead_connections.append(connection)
# Remove dead connections
for connection in dead_connections:
if connection in self.active_connections:
self.active_connections.remove(connection) self.active_connections.remove(connection)
if dead_connections:
logger.info(f"Removed {len(dead_connections)} dead connections")
async def broadcast(self, message: dict):
"""Broadcast via Redis pub/sub to all workers"""
message_str = json.dumps(message)
print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
try:
if redis_client:
await redis_client.publish('ppr_updates', message_str)
print(f"✓ Message published to Redis")
else:
# Fallback to local broadcast if Redis not available
print("⚠ Redis not available, falling back to local broadcast")
logger.warning("Redis not available, falling back to local broadcast")
await self.broadcast_local(message_str)
except Exception as e:
print(f"✗ Failed to publish to Redis: {e}")
logger.error(f"Failed to publish to Redis: {e}")
# Fallback to local broadcast
await self.broadcast_local(message_str)
async def start_redis_listener(self):
"""Listen for Redis pub/sub messages and broadcast to local connections"""
global redis_client, pubsub
try:
# Connect to Redis
redis_url = settings.redis_url or "redis://redis:6379"
print(f"Connecting to Redis at: {redis_url}")
redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe('ppr_updates')
print("✓ Redis listener started for PPR updates")
logger.info("Redis listener started for PPR updates")
async for message in pubsub.listen():
if message['type'] == 'message':
message_data = message['data']
print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
await self.broadcast_local(message_data)
except Exception as e:
print(f"Redis listener error: {e}")
logger.error(f"Redis listener error: {e}")
await asyncio.sleep(5) # Wait before retry
# Retry connection
if self.redis_listener_task and not self.redis_listener_task.done():
asyncio.create_task(self.start_redis_listener())
manager = ConnectionManager() manager = ConnectionManager()
@app.on_event("startup")
async def startup_event():
"""Start Redis listener when application starts"""
print("=" * 50)
print("STARTUP: Starting application and Redis listener...")
print("=" * 50)
logger.info("Starting application and Redis listener...")
manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up Redis connections on shutdown"""
logger.info("Shutting down application...")
global redis_client, pubsub
if manager.redis_listener_task:
manager.redis_listener_task.cancel()
if pubsub:
await pubsub.unsubscribe('ppr_updates')
await pubsub.close()
if redis_client:
await redis_client.close()
@app.websocket("/ws/tower-updates") @app.websocket("/ws/tower-updates")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket) await manager.connect(websocket)
@@ -69,7 +176,29 @@ async def root():
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
return {"status": "healthy", "timestamp": "2024-01-01T00:00:00Z"} """Health check endpoint with database connectivity verification"""
from datetime import datetime
from sqlalchemy import text
from app.db.session import SessionLocal
health_status = {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": "2.0.0"
}
# Check database connectivity
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
health_status["database"] = "connected"
except Exception as e:
health_status["status"] = "unhealthy"
health_status["database"] = "disconnected"
health_status["error"] = str(e)
return health_status
# Include API router # Include API router
app.include_router(api_router, prefix=settings.api_v1_str) app.include_router(api_router, prefix=settings.api_v1_str)

View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
from sqlalchemy.ext.declarative import declarative_base
from enum import Enum
from datetime import datetime
Base = declarative_base()
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class Arrival(Base):
__tablename__ = "arrivals"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=True)
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False)
in_from = Column(String(4), nullable=False, index=True)
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
eta = Column(DateTime, nullable=True, index=True)
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, DateTime, BigInteger, ForeignKey
from sqlalchemy.sql import func
from app.db.session import Base
class Circuit(Base):
__tablename__ = "circuits"
id = Column(BigInteger, primary_key=True, autoincrement=True)
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True)
circuit_timestamp = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())

View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
from sqlalchemy.ext.declarative import declarative_base
from enum import Enum
from datetime import datetime
Base = declarative_base()
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
CANCELLED = "CANCELLED"
class Departure(Base):
__tablename__ = "departures"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=True)
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False)
out_to = Column(String(64), nullable=False, index=True)
status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
departed_dt = Column(DateTime, nullable=True) # Actual departure time
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,34 @@
from sqlalchemy import Column, BigInteger, String, Text, DateTime, Index, func
from datetime import datetime
from enum import Enum as PyEnum
from app.db.session import Base
class EntityType(str, PyEnum):
"""Entity types that can have journal entries"""
PPR = "PPR"
LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
OVERFLIGHT = "OVERFLIGHT"
class JournalEntry(Base):
"""
Generic journal table for tracking changes across all entity types.
Replaces the PPR-specific journal table.
"""
__tablename__ = "journal"
id = Column(BigInteger, primary_key=True, autoincrement=True)
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the entity
entry = Column(Text, nullable=False)
user = Column(String(50), nullable=False, index=True)
ip = Column(String(45), nullable=True) # Made optional for new entries
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
# Composite index for efficient queries
__table_args__ = (
Index('idx_entity_lookup', 'entity_type', 'entity_id'),
)

View File

@@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger
from sqlalchemy.sql import func
from enum import Enum
from app.db.session import Base
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
DEPARTURE = "DEPARTURE"
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class LocalFlight(Base):
__tablename__ = "local_flights"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=False) # Aircraft type
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False) # Persons on board
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes
circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger
from sqlalchemy.sql import func
from enum import Enum
from app.db.session import Base
class OverflightStatus(str, Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
CANCELLED = "CANCELLED"
class Overflight(Base):
__tablename__ = "overflights"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
pob = Column(Integer, nullable=True) # Persons on board
type = Column(String(32), nullable=True) # Aircraft type
departure_airfield = Column(String(64), nullable=True, index=True) # Airfield they departed from
destination_airfield = Column(String(64), nullable=True, index=True) # Where they're heading
status = Column(SQLEnum(OverflightStatus), nullable=False, default=OverflightStatus.ACTIVE, index=True)
call_dt = Column(DateTime, nullable=False, index=True) # Time of initial call
qsy_dt = Column(DateTime, nullable=True) # Time of frequency change (QSY)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())

View File

@@ -23,25 +23,27 @@ class PPRRecord(Base):
__tablename__ = "submitted" __tablename__ = "submitted"
id = Column(BigInteger, primary_key=True, autoincrement=True) id = Column(BigInteger, primary_key=True, autoincrement=True)
status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW) status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW, index=True)
ac_reg = Column(String(16), nullable=False) ac_reg = Column(String(16), nullable=False, index=True)
ac_type = Column(String(32), nullable=False) ac_type = Column(String(32), nullable=False)
ac_call = Column(String(16), nullable=True) ac_call = Column(String(16), nullable=True)
captain = Column(String(64), nullable=False) captain = Column(String(64), nullable=False)
fuel = Column(String(16), nullable=True) fuel = Column(String(16), nullable=True)
in_from = Column(String(64), nullable=False) in_from = Column(String(64), nullable=False)
eta = Column(DateTime, nullable=False) eta = Column(DateTime, nullable=False, index=True)
pob_in = Column(Integer, nullable=False) pob_in = Column(Integer, nullable=False)
out_to = Column(String(64), nullable=True) out_to = Column(String(64), nullable=True)
etd = Column(DateTime, nullable=True) etd = Column(DateTime, nullable=True, index=True)
pob_out = Column(Integer, nullable=True) pob_out = Column(Integer, nullable=True)
email = Column(String(128), nullable=True) email = Column(String(128), nullable=True)
phone = Column(String(16), nullable=True) phone = Column(String(16), nullable=True)
notes = Column(String(2000), nullable=True) notes = Column(Text, nullable=True)
landed_dt = Column(DateTime, nullable=True) landed_dt = Column(DateTime, nullable=True)
departed_dt = Column(DateTime, nullable=True) departed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True) created_by = Column(String(16), nullable=True, index=True)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp()) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
public_token = Column(String(128), nullable=True, unique=True, index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
class User(Base): class User(Base):
@@ -51,17 +53,11 @@ class User(Base):
username = Column(String(50), nullable=False, unique=True, index=True) username = Column(String(50), nullable=False, unique=True, index=True)
password = Column(String(255), nullable=False) password = Column(String(255), nullable=False)
role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY) role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY)
email = Column(String(128), nullable=True)
full_name = Column(String(100), nullable=True)
class Journal(Base): is_active = Column(Integer, nullable=False, default=1) # Using Integer for BOOLEAN
__tablename__ = "journal" created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
id = Column(BigInteger, primary_key=True, autoincrement=True)
ppr_id = Column(Integer, nullable=False, index=True)
entry = Column(Text, nullable=False)
user = Column(Text, nullable=False)
ip = Column(Text, nullable=False)
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
class Airport(Base): class Airport(Base):
@@ -84,12 +80,12 @@ class Aircraft(Base):
__tablename__ = "aircraft" __tablename__ = "aircraft"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
icao24 = Column(String(6), nullable=True) icao24 = Column(String(6), nullable=True, index=True)
registration = Column(String(25), nullable=True, index=True) registration = Column(String(25), nullable=True, index=True)
manufacturer_icao = Column(String(50), nullable=True) manufacturer_icao = Column(String(50), nullable=True)
type_code = Column(String(30), nullable=True) type_code = Column(String(30), nullable=True, index=True)
manufacturer_name = Column(String(255), nullable=True) manufacturer_name = Column(String(255), nullable=True)
model = Column(String(255), nullable=True) model = Column(String(255), nullable=True)
clean_reg = Column(String(25), nullable=True, index=True) clean_reg = Column(String(25), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())

View File

@@ -0,0 +1,68 @@
from pydantic import BaseModel, validator
from datetime import datetime
from typing import Optional
from enum import Enum
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class ArrivalBase(BaseModel):
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
in_from: str
notes: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('in_from')
def validate_in_from(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Origin airport is required')
return v.strip()
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class ArrivalCreate(ArrivalBase):
eta: Optional[datetime] = None
class ArrivalUpdate(BaseModel):
registration: Optional[str] = None
type: Optional[str] = None
callsign: Optional[str] = None
pob: Optional[int] = None
in_from: Optional[str] = None
notes: Optional[str] = None
class ArrivalStatusUpdate(BaseModel):
status: ArrivalStatus
timestamp: Optional[datetime] = None
class Arrival(ArrivalBase):
id: int
status: ArrivalStatus
created_dt: datetime
eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class CircuitBase(BaseModel):
local_flight_id: int
circuit_timestamp: datetime
class CircuitCreate(CircuitBase):
pass
class CircuitUpdate(BaseModel):
circuit_timestamp: Optional[datetime] = None
class Circuit(CircuitBase):
id: int
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,65 @@
from pydantic import BaseModel, validator
from datetime import datetime
from typing import Optional
from enum import Enum
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
CANCELLED = "CANCELLED"
class DepartureBase(BaseModel):
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
out_to: str
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('out_to')
def validate_out_to(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Destination airport is required')
return v.strip()
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class DepartureCreate(DepartureBase):
pass
class DepartureUpdate(BaseModel):
registration: Optional[str] = None
type: Optional[str] = None
callsign: Optional[str] = None
pob: Optional[int] = None
out_to: Optional[str] = None
etd: Optional[datetime] = None
notes: Optional[str] = None
class DepartureStatusUpdate(BaseModel):
status: DepartureStatus
timestamp: Optional[datetime] = None
class Departure(DepartureBase):
id: int
status: DepartureStatus
created_dt: datetime
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class JournalEntryResponse(BaseModel):
"""Read-only schema for journal entries"""
id: int
entity_type: str # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
entity_id: int
entry: str
user: str
ip: Optional[str]
entry_dt: datetime
class Config:
from_attributes = True
class EntityJournalResponse(BaseModel):
"""Response containing all journal entries for an entity"""
entity_type: str
entity_id: int
entries: list[JournalEntryResponse]
total_entries: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,90 @@
from pydantic import BaseModel, validator
from datetime import datetime
from typing import Optional
from enum import Enum
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
DEPARTURE = "DEPARTURE"
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None
pob: int
flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('type', pre=True, always=False)
def validate_type(cls, v):
if v is None or (isinstance(v, str) and len(v.strip()) == 0):
return None
if isinstance(v, str):
return v.strip()
return v
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class LocalFlightCreate(LocalFlightBase):
pass
class LocalFlightUpdate(BaseModel):
registration: Optional[str] = None
type: Optional[str] = None
callsign: Optional[str] = None
pob: Optional[int] = None
flight_type: Optional[LocalFlightType] = None
duration: Optional[int] = None
status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None
circuits: Optional[int] = None
notes: Optional[str] = None
class LocalFlightStatusUpdate(BaseModel):
status: LocalFlightStatus
timestamp: Optional[datetime] = None
class LocalFlightInDBBase(LocalFlightBase):
id: int
status: LocalFlightStatus
created_dt: datetime
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
created_by: Optional[str] = None
updated_at: datetime
class Config:
from_attributes = True
class LocalFlight(LocalFlightInDBBase):
pass

View File

@@ -0,0 +1,107 @@
from pydantic import BaseModel, validator
from datetime import datetime
from typing import Optional
from enum import Enum
class OverflightStatus(str, Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
CANCELLED = "CANCELLED"
class OverflightBase(BaseModel):
registration: str # Using registration as callsign
pob: Optional[int] = None
type: Optional[str] = None # Aircraft type
departure_airfield: Optional[str] = None
destination_airfield: Optional[str] = None
call_dt: datetime # Time of initial call
qsy_dt: Optional[datetime] = None # Time of frequency change
notes: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('type')
def validate_type(cls, v):
if v and len(v.strip()) > 0:
return v.strip()
return v
@validator('departure_airfield')
def validate_departure_airfield(cls, v):
if v and len(v.strip()) > 0:
return v.strip().upper()
return v
@validator('destination_airfield')
def validate_destination_airfield(cls, v):
if v and len(v.strip()) > 0:
return v.strip().upper()
return v
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class OverflightCreate(OverflightBase):
pass
class OverflightUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
type: Optional[str] = None
departure_airfield: Optional[str] = None
destination_airfield: Optional[str] = None
call_dt: Optional[datetime] = None
qsy_dt: Optional[datetime] = None
status: Optional[OverflightStatus] = None
notes: Optional[str] = None
@validator('type')
def validate_type(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip() if v else v
@validator('departure_airfield')
def validate_departure_airfield(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip().upper() if v else v
@validator('destination_airfield')
def validate_destination_airfield(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip().upper() if v else v
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class OverflightStatusUpdate(BaseModel):
status: OverflightStatus
qsy_dt: Optional[datetime] = None
class Overflight(OverflightBase):
id: int
status: OverflightStatus
created_dt: datetime
created_by: Optional[str] = None
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -96,6 +96,25 @@ class PPR(PPRInDBBase):
pass pass
class PPRPublic(BaseModel):
"""Public schema for arrivals/departures board - excludes sensitive data"""
id: int
status: PPRStatus
ac_reg: str
ac_type: str
ac_call: Optional[str] = None
in_from: str
eta: datetime
out_to: Optional[str] = None
etd: Optional[datetime] = None
landed_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
submitted_dt: datetime
class Config:
from_attributes = True
class PPRInDB(PPRInDBBase): class PPRInDB(PPRInDBBase):
pass pass
@@ -116,6 +135,11 @@ class UserUpdate(BaseModel):
role: Optional[UserRole] = None role: Optional[UserRole] = None
class ChangePassword(BaseModel):
"""Schema for admin-initiated password changes"""
password: str
class UserInDBBase(UserBase): class UserInDBBase(UserBase):
id: int id: int
@@ -135,6 +159,7 @@ class UserInDB(UserInDBBase):
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
expires_in: int # Token expiry in seconds
class TokenData(BaseModel): class TokenData(BaseModel):

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>PPR Cancelled</title>
</head>
<body>
<h1>PPR Cancelled</h1>
<p>Dear {{ name }},</p>
<p>Your Prior Permission Request (PPR) has been cancelled.</p>
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Original Arrival: {{ arrival_time }}</li>
<li>Original Departure: {{ departure_time }}</li>
</ul>
<p>If this was not intended, please contact us.</p>
<p>Best regards,<br>Swansea Airport Team</p>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>PPR Submitted</title>
</head>
<body>
<h1>PPR Submitted Successfully</h1>
<p>Dear {{ name }},</p>
<p>Your Prior Permission Request (PPR) has been submitted.</p>
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Arrival: {{ arrival_time }}</li>
<li>Departure: {{ departure_time }}</li>
<li>Purpose: {{ purpose }}</li>
</ul>
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
<p>You will receive further updates via email.</p>
<p>Best regards,<br>Swansea Airport Team</p>
</body>
</html>

190
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,190 @@
#!/bin/bash
# Docker entrypoint script for PPR API
# Handles database migrations and data seeding automatically
# Note: We don't use 'set -e' here because we need to handle specific exit codes from Python scripts
echo "========================================="
echo "PPR API Container Starting"
echo "========================================="
# Wait for database to be ready
echo "Waiting for database to be ready..."
python3 << EOF
import sys
import time
from sqlalchemy import create_engine, text
from app.core.config import settings
max_retries = 30
retry_interval = 2
for i in range(max_retries):
try:
engine = create_engine(settings.database_url)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("✓ Database is ready")
sys.exit(0)
except Exception as e:
if i < max_retries - 1:
print(f"Database not ready yet (attempt {i+1}/{max_retries}), waiting...")
time.sleep(retry_interval)
else:
print(f"✗ Database connection failed after {max_retries} attempts: {e}")
sys.exit(1)
EOF
if [ $? -ne 0 ]; then
echo "Failed to connect to database. Exiting."
exit 1
fi
# Check if this is a fresh database or needs migration
echo ""
echo "Checking database state..."
python3 << EOF
import sys
from sqlalchemy import create_engine, text, inspect
from app.core.config import settings
try:
engine = create_engine(settings.database_url)
inspector = inspect(engine)
# Check if any tables exist
tables = inspector.get_table_names()
if not tables:
print("✓ Fresh database detected - will initialize")
sys.exit(2) # Signal fresh database
elif 'alembic_version' not in tables:
print("✓ Existing database without migration tracking - will stamp")
sys.exit(3) # Signal existing database needs stamping
else:
print("✓ Database has migration tracking - will check for updates")
sys.exit(0) # Normal migration check
except Exception as e:
print(f"✗ Error checking database: {e}")
sys.exit(1)
EOF
DB_STATE=$?
if [ $DB_STATE -eq 2 ]; then
# Fresh database - run initial migration
echo ""
echo "Initializing fresh database..."
cd /app
alembic upgrade head
echo "✓ Database schema created"
# Seed reference data
echo ""
echo "Loading reference data..."
python3 /app/seed_data.py
echo "✓ Reference data loaded"
elif [ $DB_STATE -eq 3 ]; then
# Existing database without Alembic - stamp it
echo ""
echo "Stamping existing database with migration version..."
cd /app
alembic stamp head
echo "✓ Database stamped"
# Check if reference data exists
python3 << EOF
from sqlalchemy import create_engine, text
from app.core.config import settings
engine = create_engine(settings.database_url)
with engine.connect() as conn:
airport_count = conn.execute(text("SELECT COUNT(*) FROM airports")).fetchone()[0]
aircraft_count = conn.execute(text("SELECT COUNT(*) FROM aircraft")).fetchone()[0]
if airport_count == 0 or aircraft_count == 0:
print("Reference data missing - will load")
exit(10)
else:
print(f"Reference data exists (airports: {airport_count}, aircraft: {aircraft_count})")
exit(0)
EOF
if [ $? -eq 10 ]; then
echo "Loading reference data..."
python3 /app/seed_data.py
echo "✓ Reference data loaded"
fi
elif [ $DB_STATE -eq 0 ]; then
# Database with Alembic tracking - check for pending migrations
echo ""
echo "Checking for pending migrations..."
cd /app
# Get current and head revisions (handle both hash and named revisions)
CURRENT=$(alembic current 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
HEAD=$(alembic heads 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
echo " Current: $CURRENT"
echo " Target: $HEAD"
if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then
echo "✓ Pending migrations detected"
echo "Applying migrations..."
alembic upgrade head
if [ $? -eq 0 ]; then
echo "✓ Migrations applied successfully"
else
echo "✗ Migration failed"
exit 1
fi
else
echo "✓ Database is up to date"
fi
# Check if reference data needs to be loaded
python3 << EOF
from sqlalchemy import create_engine, text
from app.core.config import settings
engine = create_engine(settings.database_url)
with engine.connect() as conn:
airport_count = conn.execute(text("SELECT COUNT(*) FROM airports")).fetchone()[0]
aircraft_count = conn.execute(text("SELECT COUNT(*) FROM aircraft")).fetchone()[0]
if airport_count == 0 or aircraft_count == 0:
print("Reference data missing - will load")
exit(10)
else:
print(f"Reference data exists (airports: {airport_count}, aircraft: {aircraft_count})")
exit(0)
EOF
if [ $? -eq 10 ]; then
echo "Loading reference data..."
python3 /app/seed_data.py
echo "✓ Reference data loaded"
fi
else
echo "✗ Database check failed"
exit 1
fi
echo ""
echo "========================================="
echo "Starting Application Server"
echo "========================================="
echo ""
# Start the application with appropriate settings
if [ "${ENVIRONMENT}" = "production" ]; then
echo "Starting in PRODUCTION mode with multiple workers..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${WORKERS:-4}
else
echo "Starting in DEVELOPMENT mode with auto-reload..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
fi

237
backend/populate_test_data.py Executable file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
Test data population script for PPR database.
Generates 30 random PPR records with various aircraft, airports, and other data.
"""
import random
import csv
from datetime import datetime, timedelta
from pathlib import Path
# Add the app directory to the Python path
import sys
sys.path.append(str(Path(__file__).parent / 'app'))
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db.session import SessionLocal
from app.models.ppr import PPRRecord, PPRStatus
from app.core.config import settings
def load_aircraft_data():
"""Load aircraft data from CSV file."""
aircraft = []
csv_path = Path(__file__).parent.parent / 'aircraft_data.csv'
try:
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
if len(row) >= 6:
aircraft.append({
'icao24': row[0],
'registration': row[1],
'manufacturer': row[2],
'type_code': row[3],
'manufacturer_name': row[4],
'model': row[5]
})
except FileNotFoundError:
print("Aircraft data file not found, using fallback data")
# Fallback aircraft data
aircraft = [
{'registration': 'G-ABCD', 'type_code': 'C172', 'manufacturer_name': 'Cessna', 'model': '172'},
{'registration': 'G-EFGH', 'type_code': 'PA28', 'manufacturer_name': 'Piper', 'model': 'PA-28'},
{'registration': 'G-IJKL', 'type_code': 'BE36', 'manufacturer_name': 'Beechcraft', 'model': 'Bonanza'},
{'registration': 'G-MNOP', 'type_code': 'R44', 'manufacturer_name': 'Robinson', 'model': 'R44'},
{'registration': 'G-QRST', 'type_code': 'C152', 'manufacturer_name': 'Cessna', 'model': '152'},
{'registration': 'G-UVWX', 'type_code': 'PA38', 'manufacturer_name': 'Piper', 'model': 'Tomahawk'},
{'registration': 'G-YZAB', 'type_code': 'C182', 'manufacturer_name': 'Cessna', 'model': '182'},
{'registration': 'G-CDEF', 'type_code': 'DR40', 'manufacturer_name': 'Robin', 'model': 'DR400'},
{'registration': 'G-GHIJ', 'type_code': 'TB20', 'manufacturer_name': 'Socata', 'model': 'TB-20'},
{'registration': 'G-KLMN', 'type_code': 'DA40', 'manufacturer_name': 'Diamond', 'model': 'DA-40'},
]
return aircraft
def load_airport_data():
"""Load airport data from CSV file."""
airports = []
csv_path = Path(__file__).parent.parent / 'airports_data_clean.csv'
try:
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
if len(row) >= 4:
airports.append({
'icao': row[0],
'iata': row[1],
'name': row[2],
'country': row[3]
})
except FileNotFoundError:
print("Airport data file not found, using fallback data")
# Fallback airport data
airports = [
{'icao': 'EGFH', 'iata': '', 'name': 'Swansea Airport', 'country': 'GB'},
{'icao': 'EGFF', 'iata': 'CWL', 'name': 'Cardiff International Airport', 'country': 'GB'},
{'icao': 'EGTE', 'iata': 'EXT', 'name': 'Exeter International Airport', 'country': 'GB'},
{'icao': 'EGGD', 'iata': 'BRS', 'name': 'Bristol Airport', 'country': 'GB'},
{'icao': 'EGHH', 'iata': 'BOH', 'name': 'Bournemouth Airport', 'country': 'GB'},
{'icao': 'EGHI', 'iata': 'SOU', 'name': 'Southampton Airport', 'country': 'GB'},
{'icao': 'EGSS', 'iata': 'STN', 'name': 'London Stansted Airport', 'country': 'GB'},
{'icao': 'EGKK', 'iata': 'LGW', 'name': 'London Gatwick Airport', 'country': 'GB'},
{'icao': 'EGLL', 'iata': 'LHR', 'name': 'London Heathrow Airport', 'country': 'GB'},
{'icao': 'EIDW', 'iata': 'DUB', 'name': 'Dublin Airport', 'country': 'IE'},
]
return airports
def generate_random_ppr(aircraft_data, airport_data):
"""Generate a random PPR record."""
# Select random aircraft
aircraft = random.choice(aircraft_data)
# Select random departure airport (not Swansea)
departure_airports = [a for a in airport_data if a['icao'] != 'EGFH']
arrival_airport = random.choice(departure_airports)
# Sometimes add a departure airport (50% chance)
departure_airport = None
if random.random() < 0.5:
departure_airports = [a for a in airport_data if a['icao'] != arrival_airport['icao']]
departure_airport = random.choice(departure_airports)
# Generate random times
now = datetime.now()
base_date = now + timedelta(days=random.randint(-7, 14)) # Past week to 2 weeks future
# ETA: sometime between 6 AM and 8 PM
eta_hour = random.randint(6, 20)
eta_minute = random.choice([0, 15, 30, 45])
eta = base_date.replace(hour=eta_hour, minute=eta_minute, second=0, microsecond=0)
# ETD: 1-4 hours after ETA (if departure planned)
etd = None
if departure_airport:
etd_hours = random.randint(1, 4)
etd = eta + timedelta(hours=etd_hours)
# Round ETD to 15-minute intervals
etd_minute = ((etd.minute // 15) * 15) % 60
etd = etd.replace(minute=etd_minute, second=0, microsecond=0)
# Random captain names
captains = [
'John Smith', 'Sarah Johnson', 'Michael Brown', 'Emma Davis', 'James Wilson',
'Olivia Taylor', 'William Anderson', 'Sophia Martinez', 'Benjamin Garcia', 'Isabella Lopez',
'Alexander Gonzalez', 'Charlotte Rodriguez', 'Daniel Lee', 'Amelia Walker', 'Matthew Hall'
]
# Random fuel types
fuel_types = [None, '100LL', 'JET A1', 'FULL']
# Random POB
pob_in = random.randint(1, 4)
pob_out = random.randint(1, 4) if departure_airport else None
# Random status
statuses = [PPRStatus.NEW, PPRStatus.CONFIRMED, PPRStatus.LANDED, PPRStatus.DEPARTED]
status = random.choice(statuses)
# Random contact info (sometimes)
email = None
phone = None
if random.random() < 0.7:
email = f"{random.choice(captains).lower().replace(' ', '.')}@example.com"
if random.random() < 0.5:
phone = f"07{random.randint(100000000, 999999999)}"
# Random notes (sometimes)
notes_options = [
None,
"Medical flight - priority handling required",
"VIP passenger on board",
"Technical stop only",
"Training flight",
"Photo flight - low level operations",
"Maintenance ferry flight",
"Charter flight",
"Private flight"
]
notes = random.choice(notes_options)
# Create PPR record
ppr = PPRRecord(
status=status,
ac_reg=aircraft['registration'],
ac_type=aircraft['type_code'] or 'UNKNOWN',
ac_call=random.choice([None, f"CALL{random.randint(100, 999)}"]),
captain=random.choice(captains),
fuel=random.choice(fuel_types),
in_from=arrival_airport['icao'],
eta=eta,
pob_in=pob_in,
out_to=departure_airport['icao'] if departure_airport else None,
etd=etd,
pob_out=pob_out,
email=email,
phone=phone,
notes=notes,
submitted_dt=now - timedelta(days=random.randint(0, 30)) # Random submission date
)
return ppr
def main():
"""Main function to populate test data."""
print("Loading aircraft and airport data...")
aircraft_data = load_aircraft_data()
airport_data = load_airport_data()
print(f"Loaded {len(aircraft_data)} aircraft records")
print(f"Loaded {len(airport_data)} airport records")
# Create database session
db: Session = SessionLocal()
try:
print("Generating and inserting 30 test PPR records...")
# Generate and insert 30 PPR records
for i in range(30):
ppr = generate_random_ppr(aircraft_data, airport_data)
db.add(ppr)
if (i + 1) % 10 == 0:
print(f"Generated {i + 1} records...")
# Commit all changes
db.commit()
print("✅ Successfully inserted 30 test PPR records!")
# Print summary
total_count = db.query(PPRRecord).count()
print(f"Total PPR records in database: {total_count}")
# Show status breakdown
status_counts = db.query(PPRRecord.status, func.count(PPRRecord.id)).group_by(PPRRecord.status).all()
print("\nStatus breakdown:")
for status, count in status_counts:
print(f" {status}: {count}")
except Exception as e:
print(f"❌ Error: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -14,4 +14,6 @@ pydantic-settings==2.0.3
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.21.1 pytest-asyncio==0.21.1
httpx==0.25.2 httpx==0.25.2
redis==5.0.1 redis==5.0.1
aiosmtplib==3.0.1
jinja2==3.1.2

174
backend/seed_data.py Normal file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
Seed reference data into the database
Loads airport and aircraft data from CSV files
"""
import os
import csv
import sys
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
from app.models.ppr import Airport, Aircraft
def load_airports(db, csv_path):
"""Load airport data from CSV"""
if not os.path.exists(csv_path):
print(f" ⚠ Airport data file not found: {csv_path}")
return 0
# Check if data already exists
existing_count = db.query(Airport).count()
if existing_count > 0:
print(f" ⚠ Airport data already exists ({existing_count} records), skipping")
return existing_count
print(f" Loading airports from {csv_path}...")
loaded = 0
batch = []
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f) # CSV has no headers
for row in reader:
if len(row) < 4:
continue # Skip invalid rows
airport = Airport(
icao=row[0].strip('"'),
iata=row[1].strip('"') if row[1].strip('"') else None,
name=row[2].strip('"'),
country=row[3].strip('"')
)
batch.append(airport)
loaded += 1
# Commit in batches of 1000
if len(batch) >= 1000:
db.bulk_save_objects(batch)
db.commit()
batch = []
print(f" Loaded {loaded} airports...", end='\r')
# Commit remaining
if batch:
db.bulk_save_objects(batch)
db.commit()
print(f" ✓ Loaded {loaded} airport records" + " " * 20)
return loaded
def load_aircraft(db, csv_path):
"""Load aircraft data from CSV"""
if not os.path.exists(csv_path):
print(f" ⚠ Aircraft data file not found: {csv_path}")
return 0
# Check if data already exists
existing_count = db.query(Aircraft).count()
if existing_count > 0:
print(f" ⚠ Aircraft data already exists ({existing_count} records), skipping")
return existing_count
print(f" Loading aircraft from {csv_path}...")
loaded = 0
batch = []
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f) # CSV has no headers
for row in reader:
if len(row) < 6:
continue # Skip invalid rows
aircraft = Aircraft(
icao24=row[0].strip('"') if row[0].strip('"') else None,
registration=row[1].strip('"') if row[1].strip('"') else None,
manufacturer_icao=row[2].strip('"') if row[2].strip('"') else None,
type_code=row[3].strip('"') if row[3].strip('"') else None,
manufacturer_name=row[4].strip('"') if row[4].strip('"') else None,
model=row[5].strip('"') if row[5].strip('"') else None
)
batch.append(aircraft)
loaded += 1
# Commit in batches of 1000
if len(batch) >= 1000:
db.bulk_save_objects(batch)
db.commit()
batch = []
print(f" Loaded {loaded} aircraft...", end='\r')
# Commit remaining
if batch:
db.bulk_save_objects(batch)
db.commit()
print(f" ✓ Loaded {loaded} aircraft records" + " " * 20)
return loaded
def main():
"""Main seeding function"""
print("Starting data seeding process...")
try:
# Create database connection
engine = create_engine(settings.database_url)
Session = sessionmaker(bind=engine)
db = Session()
# Determine CSV paths - check multiple locations
base_paths = [
Path('/app/../db-init'), # Docker mounted volume
Path('/app/db-init'), # If copied into container
Path('./db-init'), # Current directory
Path('../db-init'), # Parent directory
]
airport_csv = None
aircraft_csv = None
for base in base_paths:
if base.exists():
potential_airport = base / 'airports_data_clean.csv'
potential_aircraft = base / 'aircraft_data.csv'
if potential_airport.exists() and not airport_csv:
airport_csv = str(potential_airport)
if potential_aircraft.exists() and not aircraft_csv:
aircraft_csv = str(potential_aircraft)
if airport_csv and aircraft_csv:
break
# Load data
airports_loaded = 0
aircraft_loaded = 0
if airport_csv:
airports_loaded = load_airports(db, airport_csv)
else:
print(" ⚠ No airport CSV file found")
if aircraft_csv:
aircraft_loaded = load_aircraft(db, aircraft_csv)
else:
print(" ⚠ No aircraft CSV file found")
db.close()
print("")
print(f"Seeding complete:")
print(f" Airports: {airports_loaded:,}")
print(f" Aircraft: {aircraft_loaded:,}")
return 0
except Exception as e:
print(f"✗ Error during seeding: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

9
db-init/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM mysql:8.0
# Copy initialization SQL files
COPY init_db.sql /docker-entrypoint-initdb.d/01-schema.sql
COPY 02-import-data.sql /docker-entrypoint-initdb.d/02-import-data.sql
# Copy CSV files for import
COPY airports_data_clean.csv /var/lib/mysql-files/airports_data.csv
COPY aircraft_data.csv /var/lib/mysql-files/aircraft_data.csv

View File

Can't render this file because it is too large.

View File

Can't render this file because it is too large.

View File

@@ -41,6 +41,7 @@ CREATE TABLE submitted (
departed_dt DATETIME DEFAULT NULL, departed_dt DATETIME DEFAULT NULL,
created_by VARCHAR(16) DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL,
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indexes for better performance -- Indexes for better performance
@@ -49,7 +50,8 @@ CREATE TABLE submitted (
INDEX idx_etd (etd), INDEX idx_etd (etd),
INDEX idx_ac_reg (ac_reg), INDEX idx_ac_reg (ac_reg),
INDEX idx_submitted_dt (submitted_dt), INDEX idx_submitted_dt (submitted_dt),
INDEX idx_created_by (created_by) INDEX idx_created_by (created_by),
INDEX idx_public_token (public_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Activity journal table with foreign key -- Activity journal table with foreign key

95
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,95 @@
version: '3.8'
# Production docker-compose configuration
# This uses an external database and optimized settings
services:
# FastAPI Backend
api:
build: ./backend
restart: always
environment:
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_PORT: ${DB_PORT}
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
API_V1_STR: ${API_V1_STR}
PROJECT_NAME: ${PROJECT_NAME}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: production
WORKERS: "4"
ports:
- "${API_PORT_EXTERNAL}:8000"
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
networks:
- app_network
extra_hosts:
- "host.docker.internal:host-gateway"
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Redis for caching (optional)
redis:
image: redis:7-alpine
restart: always
networks:
- app_network
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
# Nginx web server for public frontend
web:
image: nginx:alpine
restart: always
environment:
BASE_URL: ${BASE_URL}
command: >
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
nginx -g 'daemon off;'"
ports:
- "${WEB_PORT_EXTERNAL}:80"
volumes:
- ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
networks:
- app_network
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
networks:
app_network:
driver: bridge

View File

@@ -1,26 +1,19 @@
version: '3.8' version: '3.8'
services: services:
# MySQL Database
db: db:
image: mysql:8.0 build: ./db-init
container_name: ppr_nextgen_db container_name: ppr_nextgen_db
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: rootpassword123 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ppr_nextgen MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ppr_user MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ppr_password123 MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3307:3306" # Use different port to avoid conflicts
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
- ./init_db.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./02-import-data.sql:/docker-entrypoint-initdb.d/02-import-data.sql
- ./airports_data_clean.csv:/var/lib/mysql-files/airports_data.csv
- ./aircraft_data.csv:/var/lib/mysql-files/aircraft_data.csv
networks: networks:
- ppr_network - private_network
# FastAPI Backend # FastAPI Backend
api: api:
@@ -28,49 +21,63 @@ services:
container_name: ppr_nextgen_api container_name: ppr_nextgen_api
restart: unless-stopped restart: unless-stopped
environment: environment:
DB_HOST: db DB_HOST: ${DB_HOST}
DB_USER: ppr_user DB_USER: ${DB_USER}
DB_PASSWORD: ppr_password123 DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ppr_nextgen DB_NAME: ${DB_NAME}
DB_PORT: 3306 DB_PORT: ${DB_PORT}
SECRET_KEY: super-secret-key-for-nextgen-ppr-system-change-in-production SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: 30 ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
API_V1_STR: /api/v1 API_V1_STR: ${API_V1_STR}
PROJECT_NAME: "Airfield PPR API NextGen" PROJECT_NAME: ${PROJECT_NAME}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: ${ENVIRONMENT}
ports: ports:
- "8001:8000" # Use different port to avoid conflicts with existing system - "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
depends_on: depends_on:
- db - db
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
networks: networks:
- ppr_network - private_network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - public_network
# Nginx web server for public frontend
web:
image: nginx:alpine
restart: unless-stopped
environment:
BASE_URL: ${BASE_URL}
command: >
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
nginx -g 'daemon off;'"
ports:
- "${WEB_PORT_EXTERNAL}:80"
volumes:
- ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
networks:
- public_network
# Redis for caching (optional for now) # Redis for caching (optional for now)
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: ppr_nextgen_redis container_name: ppr_nextgen_redis
restart: unless-stopped restart: unless-stopped
ports:
- "6380:6379" # Use different port
networks: networks:
- ppr_network - private_network
# Nginx web server for public frontend
web:
image: nginx:alpine
container_name: ppr_nextgen_web
restart: unless-stopped
ports:
- "8082:80" # Public web interface
volumes:
- ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- api
networks:
- ppr_network
# phpMyAdmin for database management # phpMyAdmin for database management
phpmyadmin: phpmyadmin:
@@ -78,21 +85,24 @@ services:
container_name: ppr_nextgen_phpmyadmin container_name: ppr_nextgen_phpmyadmin
restart: unless-stopped restart: unless-stopped
environment: environment:
PMA_HOST: db PMA_HOST: ${PMA_HOST}
PMA_PORT: 3306 PMA_PORT: ${DB_PORT}
PMA_USER: ppr_user PMA_USER: ${DB_USER}
PMA_PASSWORD: ppr_password123 PMA_PASSWORD: ${DB_PASSWORD}
UPLOAD_LIMIT: 50M UPLOAD_LIMIT: ${UPLOAD_LIMIT}
ports: ports:
- "8083:80" # phpMyAdmin web interface - "${PMA_PORT_EXTERNAL}:80" # phpMyAdmin web interface
depends_on: depends_on:
- db - db
networks: networks:
- ppr_network - private_network
- public_network
volumes: volumes:
mysql_data: mysql_data:
networks: networks:
ppr_network: private_network:
driver: bridge
public_network:
driver: bridge driver: bridge

View File

@@ -29,9 +29,24 @@ http {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Allow ppr.html to be embedded in iframes from any origin
location = /ppr.html {
add_header X-Frame-Options "ALLOWALL" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors *" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# Serve HTML files without .html extension (e.g., /admin -> admin.html)
location ~ ^/([a-zA-Z0-9_-]+)$ {
try_files /$1.html =404;
}
# Serve static files # Serve static files
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ =404;
# Apply X-Frame-Options to other files
add_header X-Frame-Options "SAMEORIGIN" always;
} }
# Proxy API requests to FastAPI backend # Proxy API requests to FastAPI backend
@@ -53,13 +68,21 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeout settings (prevent connection drops)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
# Additional WebSocket connection settings
proxy_buffering off;
} }
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; # Default CSP for other files
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self'" always;
} }
} }

16
populate_test_data.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Script to populate test data in the PPR database
# Run this from the nextgen directory
echo "Populating test data..."
# Check if we're in the right directory
if [ ! -f "docker-compose.yml" ]; then
echo "Error: Please run this script from the nextgen directory"
exit 1
fi
# Run the population script in the backend container
sudo docker compose exec api python populate_test_data.py
echo "Test data population complete!"

762
web/admin.css Normal file
View File

@@ -0,0 +1,762 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
padding-bottom: 40px; /* Make room for footer */
}
.top-bar {
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: relative;
z-index: 100;
}
.title {
order: 2;
flex: 1;
text-align: center;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.menu-buttons {
order: 1;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.top-bar .user-info {
order: 3;
font-size: 0.9rem;
opacity: 0.9;
display: flex;
align-items: center;
gap: 0.3rem;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-toggle {
white-space: nowrap;
}
.dropdown-menu {
display: none;
position: absolute;
background-color: white;
min-width: 200px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
border-radius: 5px;
z-index: 1000;
top: 100%;
left: 0;
margin-top: 0.5rem;
padding: 0;
}
.dropdown-menu a {
color: #333;
padding: 0.75rem 1.5rem;
text-decoration: none;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.shortcut {
font-size: 0.8rem;
color: #999;
margin-left: 1rem;
}
.dropdown-menu a:hover {
background-color: #f5f5f5;
}
.dropdown-menu a:first-child {
border-radius: 5px 5px 0 0;
}
.dropdown-menu a:last-child {
border-radius: 0 0 5px 5px;
}
.dropdown-menu.active {
display: block;
}
.top-bar .user-info {
font-size: 0.9rem;
opacity: 0.9;
display: flex;
align-items: center;
gap: 0.3rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.btn {
padding: 0.7rem 1.5rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-success {
background-color: #27ae60;
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-warning {
background-color: #f39c12;
color: white;
}
.btn-warning:hover {
background-color: #e67e22;
}
.btn-info {
background-color: #3498db;
color: white;
}
.btn-info:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-icon {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
min-width: auto;
}
.btn-icon:hover {
transform: scale(1.05);
}
.filter-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.filter-group label {
font-weight: 500;
color: #555;
}
.filter-group select, .filter-group input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.ppr-table {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.table-header {
background: #34495e;
color: white;
padding: 1rem;
font-weight: 500;
}
.info-icon {
display: inline-block;
cursor: pointer;
font-size: 1.2rem;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.info-icon:hover {
opacity: 1;
}
.table-header-collapsible {
background: #34495e;
color: white;
padding: 1rem;
font-weight: 500;
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-header-collapsible:hover {
background: #3d5a6e;
}
.collapse-icon {
transition: transform 0.3s ease;
font-size: 1.2rem;
}
.collapse-icon.collapsed {
transform: rotate(-90deg);
}
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #34495e;
color: white;
padding: 0.5rem 2rem;
text-align: center;
font-size: 0.85rem;
z-index: 50;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 1.4rem;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.status {
display: inline-block;
padding: 0.3rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status.new { background: #e3f2fd; color: #1565c0; }
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
.status.landed { background: #fff3e0; color: #ef6c00; }
.status.departed { background: #fce4ec; color: #c2185b; }
.status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
.no-data {
text-align: center;
padding: 3rem;
color: #666;
}
.notes-indicator {
display: inline-block;
background-color: #ffc107;
color: #856404;
font-size: 0.8rem;
padding: 2px 6px;
border-radius: 10px;
margin-left: 5px;
cursor: help;
font-weight: 600;
}
.notes-tooltip {
position: relative;
}
.notes-tooltip .tooltip-text {
visibility: hidden;
width: 300px;
background-color: #333;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 8px;
position: fixed;
z-index: 10000;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.9rem;
line-height: 1.4;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
pointer-events: none;
}
.notes-tooltip .tooltip-text::after {
content: "";
position: absolute;
top: 50%;
left: -5px;
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent #333 transparent transparent;
}
/* .notes-tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
} */
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.modal-header {
background: #34495e;
color: white;
padding: 1rem 1.5rem;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.3rem;
}
.close {
color: white;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
border: none;
background: none;
}
.close:hover {
opacity: 0.7;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem;
text-align: right;
border-top: 1px solid #ddd;
}
.modal-footer .btn {
margin-left: 0.5rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
color: #555;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
#login-form .form-group {
margin-bottom: 1rem;
}
#login-form .form-group input {
width: 100%;
}
#login-error {
background-color: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 4px;
padding: 0.8rem;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.journal-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.journal-entries {
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
padding: 1rem;
background-color: #f9f9f9;
}
.journal-entry {
margin-bottom: 0.8rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid #ddd;
}
.journal-entry:last-child {
border-bottom: none;
margin-bottom: 0;
}
.journal-meta {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.3rem;
}
.journal-text {
font-size: 0.9rem;
color: #333;
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Aircraft Lookup Styles */
#aircraft-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.aircraft-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.aircraft-no-match {
color: #6c757d;
font-style: italic;
}
.aircraft-searching {
color: #007bff;
}
/* Airport Lookup Styles */
#arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.airport-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.airport-no-match {
color: #6c757d;
font-style: italic;
}
.airport-searching {
color: #007bff;
}
.airport-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
}
.airport-option {
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.airport-option:hover {
background-color: #f8f9fa;
}
.airport-option:last-child {
border-bottom: none;
}
.airport-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #495057;
}
.airport-name {
color: #6c757d;
font-size: 0.85rem;
}
.airport-location {
color: #868e96;
font-size: 0.8rem;
font-style: italic;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background-color: #27ae60;
color: white;
padding: 12px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
font-weight: 500;
pointer-events: none;
}
.notification.show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.notification.error {
background-color: #e74c3c;
}
/* Unified Lookup Styles */
.lookup-no-match {
color: #6c757d;
font-style: italic;
}
.lookup-searching {
color: #007bff;
}
.lookup-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
}
.lookup-option {
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.lookup-option:hover {
background-color: #f8f9fa;
}
.lookup-option-selected {
background-color: #e3f2fd;
border-left: 3px solid #2196f3;
padding-left: calc(0.5rem - 3px);
}
.lookup-option-selected:hover {
background-color: #bbdefb;
}
.lookup-option:last-child {
border-bottom: none;
}
.lookup-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #495057;
}
.lookup-name {
color: #6c757d;
font-size: 0.85rem;
}
.lookup-location {
color: #868e96;
font-size: 0.8rem;
font-style: italic;
}

File diff suppressed because it is too large Load Diff

27
web/assets/bell.svg Normal file
View File

@@ -0,0 +1,27 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Bell body -->
<path d="M 30,40 Q 30,35 35,35 L 65,35 Q 70,35 70,40 Q 70,60 50,70 Q 30,60 30,40" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
<!-- Bell shine/highlight -->
<ellipse cx="45" cy="45" rx="8" ry="6" fill="#FFED4E" opacity="0.6"/>
<!-- Bell clapper -->
<circle cx="50" cy="65" r="4" fill="#8B4513"/>
<path d="M 50,65 Q 48,75 47,85" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Top of bell (rope/hanging part) -->
<rect x="47" y="25" width="6" height="10" fill="#DAA520" rx="2"/>
<!-- Loop -->
<path d="M 48,25 Q 40,20 50,15 Q 60,20 52,25" stroke="#DAA520" stroke-width="2" fill="none"/>
<!-- Decorative berries around bell -->
<circle cx="25" cy="50" r="2" fill="#E74C3C"/>
<circle cx="75" cy="50" r="2" fill="#E74C3C"/>
<circle cx="28" cy="60" r="2" fill="#E74C3C"/>
<circle cx="72" cy="60" r="2" fill="#E74C3C"/>
<!-- Holly leaves -->
<path d="M 20,45 L 18,48 L 20,50 L 18,52 L 20,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
<path d="M 80,45 L 82,48 L 80,50 L 82,52 L 80,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

10
web/assets/candycane.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Candy cane curve -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#E74C3C" stroke-width="12" fill="none" stroke-linecap="round"/>
<!-- White stripe -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#FFFFFF" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="8,8"/>
<!-- Highlight -->
<path d="M 48,15 Q 32,32 32,60 Q 32,88 48,98" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

BIN
web/assets/flightImg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

25
web/assets/gift.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Box -->
<rect x="15" y="30" width="70" height="70" fill="#E74C3C" stroke="#C0392B" stroke-width="2"/>
<!-- Box lid/3D effect -->
<polygon points="15,30 25,20 85,20 75,30" fill="#C0392B"/>
<polygon points="75,30 85,20 85,90 75,100" fill="#A93226"/>
<!-- Ribbon vertical -->
<rect x="42" y="20" width="16" height="85" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Ribbon horizontal -->
<rect x="10" y="57" width="80" height="16" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Bow on top -->
<ellipse cx="35" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<ellipse cx="65" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<circle cx="50" cy="20" r="5" fill="#DAA520"/>
<!-- Pattern on box -->
<circle cx="30" cy="50" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="70" cy="60" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="50" cy="75" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="35" cy="80" r="3" fill="#FFFFFF" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
web/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

39
web/assets/reindeer.svg Normal file
View File

@@ -0,0 +1,39 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Antlers -->
<path d="M 35,25 Q 25,10 20,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 65,25 Q 75,10 80,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 33,22 Q 22,15 15,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<path d="M 67,22 Q 78,15 85,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Head -->
<circle cx="50" cy="35" r="15" fill="#8B4513"/>
<!-- Ears -->
<ellipse cx="38" cy="22" rx="5" ry="8" fill="#8B4513"/>
<ellipse cx="62" cy="22" rx="5" ry="8" fill="#8B4513"/>
<!-- Eyes -->
<circle cx="45" cy="32" r="2" fill="#000000"/>
<circle cx="55" cy="32" r="2" fill="#000000"/>
<!-- Nose (red) -->
<circle cx="50" cy="40" r="4" fill="#E74C3C"/>
<!-- Mouth -->
<path d="M 48,45 Q 50,47 52,45" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Neck -->
<rect x="43" y="48" width="14" height="8" fill="#8B4513"/>
<!-- Body -->
<ellipse cx="50" cy="70" rx="20" ry="25" fill="#8B4513"/>
<!-- Legs -->
<rect x="35" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="45" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="55" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="65" y="90" width="6" height="25" fill="#8B4513"/>
<!-- Tail -->
<circle cx="68" cy="65" r="5" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
web/assets/santa.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Santa hat -->
<polygon points="20,20 80,20 75,35 25,35" fill="#E74C3C"/>
<circle cx="50" cy="18" r="8" fill="#E74C3C"/>
<circle cx="77" cy="28" r="6" fill="#FFFFFF"/>
<!-- Face -->
<circle cx="50" cy="50" r="18" fill="#F5DEB3"/>
<!-- Eyes -->
<circle cx="42" cy="45" r="2.5" fill="#000000"/>
<circle cx="58" cy="45" r="2.5" fill="#000000"/>
<!-- Nose -->
<circle cx="50" cy="52" r="2" fill="#E74C3C"/>
<!-- Beard -->
<path d="M 35,58 Q 35,65 50,68 Q 65,65 65,58" fill="#FFFFFF"/>
<!-- Mouth -->
<path d="M 42,60 Q 50,63 58,60" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Body -->
<rect x="35" y="68" width="30" height="25" rx="5" fill="#E74C3C"/>
<!-- Belt -->
<rect x="32" y="85" width="36" height="4" fill="#000000"/>
<circle cx="68" cy="87" r="2.5" fill="#FFD700"/>
<!-- Arms -->
<rect x="15" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<rect x="65" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<!-- Legs -->
<rect x="40" y="93" width="8" height="20" fill="#000000"/>
<rect x="52" y="93" width="8" height="20" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

17
web/assets/tree.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Tree trunk -->
<rect x="40" y="80" width="20" height="25" fill="#8B4513"/>
<!-- Tree layers -->
<polygon points="50,10 20,50 30,50 10,80 40,80 5,110 50,110 95,110 60,80 90,80 70,50 80,50" fill="#228B22"/>
<!-- Tree highlights -->
<circle cx="50" cy="35" r="4" fill="#FFD700" opacity="0.7"/>
<circle cx="35" cy="55" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="65" cy="60" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="45" cy="80" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="55" cy="90" r="3" fill="#FFD700" opacity="0.7"/>
<!-- Star on top -->
<polygon points="50,5 55,15 65,15 57,20 60,30 50,25 40,30 43,20 35,15 45,15" fill="#FFD700"/>
</svg>

After

Width:  |  Height:  |  Size: 758 B

792
web/edit.html Normal file
View File

@@ -0,0 +1,792 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Swansea PPR Request</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #3498db;
}
.header h1 {
color: #2c3e50;
margin-bottom: 0.5rem;
}
.header p {
color: #666;
font-size: 1.1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
color: #555;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.required::after {
content: " *";
color: #e74c3c;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: center;
padding-top: 1rem;
border-top: 1px solid #eee;
margin-top: 2rem;
}
.btn {
padding: 0.8rem 2rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background-color: #27ae60;
color: white;
padding: 12px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
font-weight: 500;
}
.notification.show {
opacity: 1;
transform: translateY(0);
}
.notification.error {
background-color: #e74c3c;
}
.loading {
display: none;
text-align: center;
margin-top: 1rem;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.success-message {
display: none;
text-align: center;
padding: 2rem;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 4px;
color: #155724;
margin-top: 2rem;
}
.airport-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.aircraft-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.lookup-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.lookup-no-match {
color: #6c757d;
font-style: italic;
}
.lookup-searching {
color: #007bff;
}
.aircraft-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
}
.aircraft-option {
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.aircraft-option:hover {
background-color: #f8f9fa;
}
.aircraft-option:last-child {
border-bottom: none;
}
.aircraft-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #495057;
}
.aircraft-details {
color: #6c757d;
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✏️ Edit Swansea PPR Request</h1>
<p>Update your Prior Permission Required (PPR) request details below.</p>
</div>
<form id="ppr-form">
<div class="form-grid">
<div class="form-group">
<label for="ac_reg" class="required">Aircraft Registration</label>
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
<div id="aircraft-lookup-results" class="aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="ac_type" class="required">Aircraft Type</label>
<input type="text" id="ac_type" name="ac_type" required>
</div>
<div class="form-group">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
</div>
<div class="form-group">
<label for="captain" class="required">Captain/Pilot Name</label>
<input type="text" id="captain" name="captain" required>
</div>
<div class="form-group">
<label for="in_from" class="required">Arriving From</label>
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
<div id="arrival-airport-lookup-results" class="airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="eta" class="required">Estimated Time of Arrival (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
<select id="eta-time" name="eta-time" required style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_in" class="required">Persons on Board (Arrival)</label>
<input type="number" id="pob_in" name="pob_in" required min="1">
</div>
<div class="form-group">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
<option value="FULL">Full Tanks</option>
</select>
</div>
<div class="form-group">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
<div id="departure-airport-lookup-results" class="airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="etd">Estimated Time of Departure (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" style="flex: 1;">
<select id="etd-time" name="etd-time" style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_out">Persons on Board (Departure)</label>
<input type="number" id="pob_out" name="pob_out" min="1">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email">
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone">
</div>
<div class="form-group full-width">
<label for="notes">Additional Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="update-btn">
Update PPR Request
</button>
<button type="button" class="btn btn-danger" id="cancel-btn">
Cancel PPR Request
</button>
</div>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
Processing your request...
</div>
<div class="success-message" id="success-message">
<h3>✅ PPR Request Updated Successfully!</h3>
<p>Your changes have been saved.</p>
</div>
<div class="success-message" id="cancel-message">
<h3>❌ PPR Request Cancelled</h3>
<p>Your PPR request has been cancelled.</p>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<script>
// Get token from URL
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (!token) {
alert('Invalid link. No token provided.');
window.location.href = '/';
}
// Initialize time dropdowns
function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time'];
timeSelects.forEach(selectId => {
const select = document.getElementById(selectId);
// Clear existing options except the first one
select.innerHTML = '<option value="">Select Time</option>';
// Add time options in 15-minute intervals
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const option = document.createElement('option');
option.value = timeString;
option.textContent = timeString;
select.appendChild(option);
}
}
});
}
// Notification system
function showNotification(message, isError = false) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
setTimeout(() => {
notification.classList.add('show');
}, 10);
setTimeout(() => {
notification.classList.remove('show');
}, 5000);
}
// Load PPR data
async function loadPPRData() {
try {
const response = await fetch(`/api/v1/pprs/public/edit/${token}`);
if (response.ok) {
const ppr = await response.json();
// Populate form
document.getElementById('ac_reg').value = ppr.ac_reg || '';
document.getElementById('ac_type').value = ppr.ac_type || '';
document.getElementById('ac_call').value = ppr.ac_call || '';
document.getElementById('captain').value = ppr.captain || '';
document.getElementById('in_from').value = ppr.in_from || '';
// Handle ETA date/time separately
if (ppr.eta) {
let utcDateStr = ppr.eta;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const etaDate = new Date(utcDateStr);
const etaDateStr = etaDate.toISOString().split('T')[0];
const etaTimeStr = etaDate.toISOString().slice(11, 16);
document.getElementById('eta-date').value = etaDateStr;
document.getElementById('eta-time').value = etaTimeStr;
}
document.getElementById('pob_in').value = ppr.pob_in || '';
document.getElementById('fuel').value = ppr.fuel || '';
document.getElementById('out_to').value = ppr.out_to || '';
// Handle ETD date/time separately
if (ppr.etd) {
let utcDateStr = ppr.etd;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const etdDate = new Date(utcDateStr);
const etdDateStr = etdDate.toISOString().split('T')[0];
const etdTimeStr = etdDate.toISOString().slice(11, 16);
document.getElementById('etd-date').value = etdDateStr;
document.getElementById('etd-time').value = etdTimeStr;
}
document.getElementById('pob_out').value = ppr.pob_out || '';
document.getElementById('email').value = ppr.email || '';
document.getElementById('phone').value = ppr.phone || '';
document.getElementById('notes').value = ppr.notes || '';
} else {
throw new Error('Failed to load PPR data');
}
} catch (error) {
console.error('Error loading PPR:', error);
showNotification('Error loading PPR data', true);
}
}
// Aircraft lookup (same as submit form)
let aircraftLookupTimeout;
async function handleAircraftLookup(registration) {
clearTimeout(aircraftLookupTimeout);
const resultsDiv = document.getElementById('aircraft-lookup-results');
const regField = document.getElementById('ac_reg');
if (!registration || registration.length < 4) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
aircraftLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/aircraft/public/lookup/${registration.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
const aircraft = data[0];
regField.value = aircraft.registration || registration.toUpperCase();
resultsDiv.innerHTML = `
<div class="lookup-match">
${aircraft.registration || registration.toUpperCase()} - ${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}
</div>
`;
if (!document.getElementById('ac_type').value) {
document.getElementById('ac_type').value = aircraft.type_code || '';
}
} else if (data.length <= 10) {
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(aircraft => `
<div class="aircraft-option" onclick="selectAircraft('${aircraft.registration || registration.toUpperCase()}', '${aircraft.type_code || ''}')">
<div class="aircraft-code">${aircraft.registration || registration.toUpperCase()}</div>
<div class="aircraft-details">${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}</div>
</div>
`).join('')}
</div>
`;
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No aircraft found with this registration</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Aircraft lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
function selectAircraft(registration, typeCode) {
document.getElementById('ac_reg').value = registration;
document.getElementById('ac_type').value = typeCode;
document.getElementById('aircraft-lookup-results').innerHTML = '';
document.getElementById('ac_reg').blur();
}
document.getElementById('ac_reg').addEventListener('blur', function() {
setTimeout(() => {
document.getElementById('aircraft-lookup-results').innerHTML = '';
}, 150);
});
// Airport lookup functions (same as submit)
let arrivalAirportLookupTimeout;
async function handleArrivalAirportLookup(query) {
clearTimeout(arrivalAirportLookupTimeout);
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
const inputField = document.getElementById('in_from');
if (!query || query.length < 2) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
arrivalAirportLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code
const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = `
<div class="lookup-match">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
</div>
`;
} else if (data.length <= 10) {
// Multiple matches - show as clickable list
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(airport => `
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
`).join('')}
</div>
`;
} else {
// Too many matches
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Arrival airport lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
let departureAirportLookupTimeout;
async function handleDepartureAirportLookup(query) {
clearTimeout(departureAirportLookupTimeout);
const resultsDiv = document.getElementById('departure-airport-lookup-results');
const inputField = document.getElementById('out_to');
if (!query || query.length < 2) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
departureAirportLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code
const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = `
<div class="lookup-match">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
</div>
`;
} else if (data.length <= 10) {
// Multiple matches - show as clickable list
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(airport => `
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
`).join('')}
</div>
`;
} else {
// Too many matches
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Departure airport lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
function selectAirport(fieldId, icaoCode) {
document.getElementById(fieldId).value = icaoCode;
// Clear the results
const resultsDivId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
document.getElementById(resultsDivId).innerHTML = '';
// Remove focus from the field to hide the dropdown
document.getElementById(fieldId).blur();
}
// Clear airport lookup results when input loses focus
document.getElementById('in_from').addEventListener('blur', function() {
setTimeout(() => {
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
}, 150);
});
document.getElementById('out_to').addEventListener('blur', function() {
setTimeout(() => {
document.getElementById('departure-airport-lookup-results').innerHTML = '';
}, 150);
});
// Form submission (update)
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const pprData = {};
formData.forEach((value, key) => {
if (value.trim() !== '') {
if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = parseInt(value);
} else if (key === 'eta-date' && formData.get('eta-time')) {
// Combine date and time for ETA
const dateStr = formData.get('eta-date');
const timeStr = formData.get('eta-time');
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key === 'etd-date' && formData.get('etd-time')) {
// Combine date and time for ETD
const dateStr = formData.get('etd-date');
const timeStr = formData.get('etd-time');
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key !== 'eta-time' && key !== 'etd-time') {
// Skip the time fields as they're handled above
pprData[key] = value;
}
}
});
document.getElementById('loading').style.display = 'block';
document.getElementById('update-btn').disabled = true;
document.getElementById('update-btn').textContent = 'Updating...';
try {
const response = await fetch(`/api/v1/pprs/public/edit/${token}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(pprData)
});
if (response.ok) {
document.getElementById('ppr-form').style.display = 'none';
document.getElementById('success-message').style.display = 'block';
showNotification('PPR request updated successfully!');
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Update failed: ${response.status}`);
}
} catch (error) {
console.error('Error updating PPR:', error);
showNotification(`Error updating PPR: ${error.message}`, true);
} finally {
document.getElementById('loading').style.display = 'none';
document.getElementById('update-btn').disabled = false;
document.getElementById('update-btn').textContent = 'Update PPR Request';
}
});
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', async function() {
if (!confirm('Are you sure you want to cancel this PPR request?')) {
return;
}
document.getElementById('loading').style.display = 'block';
this.disabled = true;
this.textContent = 'Cancelling...';
try {
const response = await fetch(`/api/v1/pprs/public/cancel/${token}`, {
method: 'DELETE'
});
if (response.ok) {
document.getElementById('ppr-form').style.display = 'none';
document.getElementById('cancel-message').style.display = 'block';
showNotification('PPR request cancelled successfully!');
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Cancellation failed: ${response.status}`);
}
} catch (error) {
console.error('Error cancelling PPR:', error);
showNotification(`Error cancelling PPR: ${error.message}`, true);
} finally {
document.getElementById('loading').style.display = 'none';
this.disabled = false;
this.textContent = 'Cancel PPR Request';
}
});
// Load data on page load
loadPPRData();
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
initializeTimeDropdowns();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

503
web/lookups.js Normal file
View File

@@ -0,0 +1,503 @@
/**
* Lookup Utilities - Reusable functions for aircraft and airport lookups
*/
/**
* Format aircraft registration based on UK rules
* - 5 alphabetic chars: add hyphen after first char (GIVYY -> G-IVYY)
* - Otherwise: just uppercase (N123AD -> N123AD)
*/
function formatAircraftRegistration(input) {
if (!input) return '';
const cleaned = input.trim().toUpperCase();
// If exactly 5 characters and all alphabetic, add hyphen
if (cleaned.length === 5 && /^[A-Z]{5}$/.test(cleaned)) {
return cleaned[0] + '-' + cleaned.substring(1);
}
// Otherwise just return uppercase version
return cleaned;
}
/**
* Creates a reusable lookup handler
* @param {string} fieldId - ID of the input field
* @param {string} resultsId - ID of the results container
* @param {function} selectCallback - Function to call when item is selected
* @param {object} options - Additional options (minLength, debounceMs, etc.)
*/
function createLookup(fieldId, resultsId, selectCallback, options = {}) {
const defaults = {
minLength: 2,
debounceMs: 300,
isAirport: false,
isAircraft: false,
maxResults: 10
};
const config = { ...defaults, ...options };
let debounceTimeout;
let currentResults = [];
let selectedIndex = -1;
let keydownHandlerAttached = false;
const lookup = {
// Main handler called by oninput
handle: (value) => {
clearTimeout(debounceTimeout);
selectedIndex = -1; // Reset selection on new input
if (!value || value.trim().length < config.minLength) {
lookup.clear();
return;
}
lookup.showSearching();
debounceTimeout = setTimeout(() => {
lookup.perform(value);
}, config.debounceMs);
},
// Attach keyboard handler once (for airport lookups)
attachKeyboardHandler: () => {
if (config.isAirport && !keydownHandlerAttached) {
try {
const inputField = document.getElementById(fieldId);
if (inputField) {
inputField.addEventListener('keydown', (e) => lookup.handleKeydown(e));
keydownHandlerAttached = true;
}
} catch (error) {
console.error('Error attaching keyboard handler:', error);
}
}
},
// Handle keyboard events
handleKeydown: (event) => {
if (!currentResults || currentResults.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1);
lookup.updateSelection();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
lookup.updateSelection();
} else if (event.key === 'Enter') {
event.preventDefault();
if (selectedIndex >= 0 && currentResults[selectedIndex]) {
lookup.selectResult(currentResults[selectedIndex]);
} else if (currentResults.length === 1) {
// Auto-select if only one result and Enter pressed
lookup.selectResult(currentResults[0]);
}
} else if (event.key === 'Escape') {
lookup.clear();
selectedIndex = -1;
}
},
// Update visual selection
updateSelection: () => {
const resultsDiv = document.getElementById(resultsId);
if (!resultsDiv) return;
const options = resultsDiv.querySelectorAll('.lookup-option');
options.forEach((opt, idx) => {
if (idx === selectedIndex) {
opt.classList.add('lookup-option-selected');
opt.scrollIntoView({ block: 'nearest' });
} else {
opt.classList.remove('lookup-option-selected');
}
});
},
// Select a result item
selectResult: (item) => {
const field = document.getElementById(fieldId);
if (field) {
field.value = item.icao;
}
lookup.clear();
currentResults = [];
selectedIndex = -1;
if (selectCallback) selectCallback(item.icao);
},
// Perform the lookup
perform: async (searchTerm) => {
try {
const cleanInput = searchTerm.trim();
let endpoint;
if (config.isAircraft) {
const cleaned = cleanInput.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (cleaned.length < config.minLength) {
lookup.clear();
return;
}
endpoint = `/api/v1/aircraft/lookup/${cleaned}`;
} else if (config.isAirport) {
endpoint = `/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`;
}
if (!endpoint) throw new Error('Invalid lookup type');
const response = await authenticatedFetch(endpoint);
if (!response.ok) throw new Error('Lookup failed');
const results = await response.json();
lookup.display(results, cleanInput);
} catch (error) {
console.error('Lookup error:', error);
lookup.showError();
}
},
// Display results
display: (results, searchTerm) => {
const resultsDiv = document.getElementById(resultsId);
if (config.isAircraft) {
// Aircraft lookup: auto-populate on single match, format input on no match
if (!results || results.length === 0) {
// Format the aircraft registration and auto-populate
const formatted = formatAircraftRegistration(searchTerm);
const field = document.getElementById(fieldId);
if (field) {
field.value = formatted;
}
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
} else if (results.length === 1) {
// Single match - auto-populate
const aircraft = results[0];
resultsDiv.innerHTML = `
<div class="aircraft-match">
${aircraft.manufacturer_name || ''} ${aircraft.model || aircraft.type_code || ''}
</div>
`;
// Auto-populate the form fields
const field = document.getElementById(fieldId);
if (field) field.value = aircraft.registration;
// Also populate type field
let typeFieldId;
if (fieldId === 'ac_reg') {
typeFieldId = 'ac_type';
} else if (fieldId === 'local_registration') {
typeFieldId = 'local_type';
} else if (fieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (fieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
if (typeFieldId) {
const typeField = document.getElementById(typeFieldId);
if (typeField) typeField.value = aircraft.type_code || '';
}
} else {
// Multiple matches
resultsDiv.innerHTML = `
<div class="aircraft-no-match">
Multiple matches found (${results.length}) - please be more specific
</div>
`;
}
} else {
// Airport lookup: show list of options with keyboard navigation
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<div class="lookup-no-match">No matches found - will use as entered</div>';
currentResults = [];
return;
}
currentResults = results.slice(0, config.maxResults);
selectedIndex = -1; // Reset selection when showing new results
const matchText = currentResults.length === 1 ? 'Match found - press ENTER or click to select:' : 'Multiple matches found - use arrow keys and ENTER to select:';
let html = `<div class="lookup-no-match" style="margin-bottom: 0.5rem;">${matchText}</div><div class="lookup-list">`;
currentResults.forEach((item, idx) => {
html += `
<div class="lookup-option" onclick="lookupManager.selectItem('${resultsId}', '${fieldId}', '${item.icao}')">
<div class="lookup-code">${item.icao}</div>
<div class="lookup-name">${item.name || '-'}</div>
${item.city ? `<div class="lookup-location">${item.city}, ${item.country}</div>` : ''}
</div>
`;
});
html += '</div>';
resultsDiv.innerHTML = html;
// Attach keyboard handler (only once per lookup instance)
lookup.attachKeyboardHandler();
}
},
// Show searching state
showSearching: () => {
const resultsDiv = document.getElementById(resultsId);
if (resultsDiv) {
resultsDiv.innerHTML = '<div class="lookup-searching">Searching...</div>';
}
},
// Show error state
showError: () => {
const resultsDiv = document.getElementById(resultsId);
if (resultsDiv) {
resultsDiv.innerHTML = '<div class="lookup-no-match">Lookup failed - will use as entered</div>';
}
},
// Clear results
clear: () => {
const resultsDiv = document.getElementById(resultsId);
if (resultsDiv) {
resultsDiv.innerHTML = '';
}
},
// Set the selected value
setValue: (value) => {
const field = document.getElementById(fieldId);
if (field) {
field.value = value;
}
lookup.clear();
if (selectCallback) selectCallback(value);
}
};
return lookup;
}
/**
* Global lookup manager for all lookups on the page
*/
const lookupManager = {
lookups: {},
// Register a lookup instance
register: (name, lookup) => {
lookupManager.lookups[name] = lookup;
},
// Generic item selection handler
selectItem: (resultsId, fieldId, itemCode) => {
const field = document.getElementById(fieldId);
if (field) {
field.value = itemCode;
}
const resultsDiv = document.getElementById(resultsId);
if (resultsDiv) {
resultsDiv.innerHTML = '';
}
}
};
// Initialize all lookups when page loads
function initializeLookups() {
// Create reusable lookup instances
const arrivalAirportLookup = createLookup(
'in_from',
'arrival-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('arrival-airport', arrivalAirportLookup);
const departureAirportLookup = createLookup(
'out_to',
'departure-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('departure-airport', departureAirportLookup);
const localOutToLookup = createLookup(
'local_out_to',
'local-out-to-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('local-out-to', localOutToLookup);
const aircraftLookup = createLookup(
'ac_reg',
'aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('aircraft', aircraftLookup);
const localAircraftLookup = createLookup(
'local_registration',
'local-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('local-aircraft', localAircraftLookup);
const bookInAircraftLookup = createLookup(
'book_in_registration',
'book-in-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('book-in-aircraft', bookInAircraftLookup);
const bookInArrivalAirportLookup = createLookup(
'book_in_from',
'book-in-arrival-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('book-in-arrival-airport', bookInArrivalAirportLookup);
const overflightAircraftLookup = createLookup(
'overflight_registration',
'overflight-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('overflight-aircraft', overflightAircraftLookup);
const overflightDepartureLookup = createLookup(
'overflight_departure_airfield',
'overflight-departure-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('overflight-departure', overflightDepartureLookup);
const overflightDestinationLookup = createLookup(
'overflight_destination_airfield',
'overflight-destination-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('overflight-destination', overflightDestinationLookup);
// Attach keyboard handlers to airport input fields
setTimeout(() => {
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
if (departureAirportLookup.attachKeyboardHandler) departureAirportLookup.attachKeyboardHandler();
if (localOutToLookup.attachKeyboardHandler) localOutToLookup.attachKeyboardHandler();
if (bookInArrivalAirportLookup.attachKeyboardHandler) bookInArrivalAirportLookup.attachKeyboardHandler();
if (overflightDepartureLookup.attachKeyboardHandler) overflightDepartureLookup.attachKeyboardHandler();
if (overflightDestinationLookup.attachKeyboardHandler) overflightDestinationLookup.attachKeyboardHandler();
}, 100);
}
// Initialize on DOM ready or immediately if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeLookups);
} else {
initializeLookups();
}
/**
* Convenience wrapper functions for backward compatibility
*/
function handleArrivalAirportLookup(value) {
const lookup = lookupManager.lookups['arrival-airport'];
if (lookup) lookup.handle(value);
}
function handleDepartureAirportLookup(value) {
const lookup = lookupManager.lookups['departure-airport'];
if (lookup) lookup.handle(value);
}
function handleLocalOutToAirportLookup(value) {
const lookup = lookupManager.lookups['local-out-to'];
if (lookup) lookup.handle(value);
}
function handleAircraftLookup(value) {
const lookup = lookupManager.lookups['aircraft'];
if (lookup) lookup.handle(value);
}
function handleLocalAircraftLookup(value) {
const lookup = lookupManager.lookups['local-aircraft'];
if (lookup) lookup.handle(value);
}
function clearArrivalAirportLookup() {
const lookup = lookupManager.lookups['arrival-airport'];
if (lookup) lookup.clear();
}
function clearDepartureAirportLookup() {
const lookup = lookupManager.lookups['departure-airport'];
if (lookup) lookup.clear();
}
function clearLocalOutToAirportLookup() {
const lookup = lookupManager.lookups['local-out-to'];
if (lookup) lookup.clear();
}
function clearAircraftLookup() {
const lookup = lookupManager.lookups['aircraft'];
if (lookup) lookup.clear();
}
function clearLocalAircraftLookup() {
const lookup = lookupManager.lookups['local-aircraft'];
if (lookup) lookup.clear();
}
function selectArrivalAirport(icaoCode) {
lookupManager.selectItem('arrival-airport-lookup-results', 'in_from', icaoCode);
}
function selectDepartureAirport(icaoCode) {
lookupManager.selectItem('departure-airport-lookup-results', 'out_to', icaoCode);
}
function selectLocalOutToAirport(icaoCode) {
lookupManager.selectItem('local-out-to-lookup-results', 'local_out_to', icaoCode);
}
function selectLocalAircraft(registration) {
lookupManager.selectItem('local-aircraft-lookup-results', 'local_registration', registration);
}
function handleBookInAircraftLookup(value) {
const lookup = lookupManager.lookups['book-in-aircraft'];
if (lookup) lookup.handle(value);
}
function handleBookInArrivalAirportLookup(value) {
const lookup = lookupManager.lookups['book-in-arrival-airport'];
if (lookup) lookup.handle(value);
}
function clearBookInAircraftLookup() {
const lookup = lookupManager.lookups['book-in-aircraft'];
if (lookup) lookup.clear();
}
function clearBookInArrivalAirportLookup() {
const lookup = lookupManager.lookups['book-in-arrival-airport'];
if (lookup) lookup.clear();
}
function selectBookInAircraft(registration) {
lookupManager.selectItem('book-in-aircraft-lookup-results', 'book_in_registration', registration);
}
function selectBookInArrivalAirport(icaoCode) {
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
}

893
web/ppr.html Normal file
View File

@@ -0,0 +1,893 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swansea PPR</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #3498db;
}
.header h1 {
color: #2c3e50;
margin-bottom: 0.5rem;
}
.header p {
color: #666;
font-size: 1.1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
color: #555;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.required::after {
content: " *";
color: #e74c3c;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: center;
padding-top: 1rem;
border-top: 1px solid #eee;
margin-top: 2rem;
}
.btn {
padding: 0.8rem 2rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-success {
background-color: #27ae60;
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background-color: #27ae60;
color: white;
padding: 12px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
font-weight: 500;
}
.notification.show {
opacity: 1;
transform: translateY(0);
}
.notification.error {
background-color: #e74c3c;
}
.loading {
display: none;
text-align: center;
margin-top: 1rem;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.success-message {
display: none;
text-align: center;
padding: 2rem;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 4px;
color: #155724;
margin-top: 2rem;
}
.airport-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.aircraft-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.lookup-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.lookup-no-match {
color: #6c757d;
font-style: italic;
}
.lookup-searching {
color: #007bff;
}
.aircraft-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
}
.aircraft-option {
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.aircraft-option:hover {
background-color: #f8f9fa;
}
.aircraft-option:last-child {
border-bottom: none;
}
.aircraft-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #495057;
}
.aircraft-details {
color: #6c757d;
font-size: 0.85rem;
}
/* Mobile responsive styles */
@media (max-width: 768px) {
.container {
margin: 1rem auto;
padding: 1rem;
}
.header h1 {
font-size: 1.3rem;
}
.header p {
font-size: 1rem;
}
.form-grid {
grid-template-columns: 1fr; /* Single column on mobile */
gap: 0.8rem;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.7rem;
font-size: 1rem; /* Prevent zoom on iOS */
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.btn {
width: 100%;
padding: 1rem;
font-size: 1rem;
}
.notification {
font-size: 0.9rem;
padding: 1rem;
}
.success-message {
padding: 1.5rem;
font-size: 0.95rem;
}
.loading {
padding: 2rem;
}
.airport-lookup-results, .aircraft-lookup-results {
max-height: 200px;
font-size: 0.9rem;
}
.aircraft-option, .airport-option {
padding: 0.8rem;
font-size: 0.9rem;
}
}
/* Extra small screens */
@media (max-width: 480px) {
.container {
margin: 0.5rem;
padding: 0.8rem;
}
.header {
margin-bottom: 1.5rem;
}
.form-grid {
gap: 0.6rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✈️ PPR Request</h1>
<p>Please fill out the form below to submit a PPR request for Swansea Airport.</p>
<p>Note that this is a new form, and is under test. Please email james.pattinson@sasalliance.org if you have any issues with the form.</p>
</div>
<form id="ppr-form">
<div class="form-grid">
<div class="form-group">
<label for="ac_reg" class="required">Aircraft Registration</label>
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
<div id="aircraft-lookup-results" class="aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="ac_type" class="required">Aircraft Type</label>
<input type="text" id="ac_type" name="ac_type" required>
</div>
<div class="form-group">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
</div>
<div class="form-group">
<label for="captain" class="required">Captain/Pilot Name</label>
<input type="text" id="captain" name="captain" required>
</div>
<div class="form-group">
<label for="in_from" class="required">Arriving From</label>
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
<div id="arrival-airport-lookup-results" class="airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="eta" class="required">Estimated Time of Arrival (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
<select id="eta-time" name="eta-time" required style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_in" class="required">Persons on Board (Arrival)</label>
<input type="number" id="pob_in" name="pob_in" required min="1">
</div>
<div class="form-group">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
</select>
</div>
<div class="form-group">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
<div id="departure-airport-lookup-results" class="airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="etd">Estimated Time of Departure (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" style="flex: 1;">
<select id="etd-time" name="etd-time" style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_out">Persons on Board (Departure)</label>
<input type="number" id="pob_out" name="pob_out" min="1">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email">
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone">
</div>
<div class="form-group full-width">
<label for="notes">Additional Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="submit-btn">
Submit PPR Request
</button>
</div>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
Submitting your PPR request...
</div>
<div class="success-message" id="success-message">
<h3>PPR Request Submitted.</h3>
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
<p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<script src="/config.js"></script>
<script>
// API base URL for iframe embedding - loaded from config.js or fallback
console.log('=== PPR Config Debug ===');
console.log('window.PPR_CONFIG:', window.PPR_CONFIG);
console.log('window.PPR_CONFIG.apiBase:', window.PPR_CONFIG?.apiBase);
const API_BASE = (window.PPR_CONFIG && window.PPR_CONFIG.apiBase) || '/api/v1';
console.log('Final API_BASE:', API_BASE);
console.log('Source:', window.PPR_CONFIG?.apiBase ? 'config.js' : 'fallback');
console.log('=======================');
// Track if user has manually edited ETD
let etdManuallyEdited = false;
// Function to update ETD based on ETA (2 hours later)
function updateETDFromETA() {
// Only auto-update if user hasn't manually edited ETD
if (etdManuallyEdited) {
return;
}
const etaDate = document.getElementById('eta-date').value;
const etaTime = document.getElementById('eta-time').value;
if (etaDate && etaTime) {
// Parse ETA
const eta = new Date(`${etaDate}T${etaTime}`);
// Calculate ETD (2 hours after ETA)
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
// Format ETD
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
// Update ETD fields
document.getElementById('etd-date').value = etdDateStr;
document.getElementById('etd-time').value = etdTimeStr;
}
}
// Function to mark ETD as manually edited
function markETDAsManuallyEdited() {
etdManuallyEdited = true;
}
// Iframe resizing functionality
function sendHeightToParent() {
const height = document.body.scrollHeight || document.documentElement.scrollHeight;
if (window.parent !== window) {
window.parent.postMessage({
type: 'setHeight',
height: height + 20 // Add some padding
}, '*');
}
}
// Send height on load and resize
window.addEventListener('load', function() {
sendHeightToParent();
// Also send height after any content changes
setTimeout(sendHeightToParent, 100);
});
window.addEventListener('resize', sendHeightToParent);
// Initialize time dropdowns
function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time'];
timeSelects.forEach(selectId => {
const select = document.getElementById(selectId);
// Clear existing options except the first one
select.innerHTML = '<option value="">Select Time</option>';
// Add time options in 15-minute intervals
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const option = document.createElement('option');
option.value = timeString;
option.textContent = timeString;
select.appendChild(option);
}
}
});
}
// Notification system
function showNotification(message, isError = false) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
// Show notification
setTimeout(() => {
notification.classList.add('show');
// Update iframe height when notification appears
setTimeout(sendHeightToParent, 50);
}, 10);
// Hide after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
// Update iframe height when notification disappears
setTimeout(sendHeightToParent, 50);
}, 5000);
}
// Aircraft lookup
let aircraftLookupTimeout;
async function handleAircraftLookup(registration) {
clearTimeout(aircraftLookupTimeout);
const resultsDiv = document.getElementById('aircraft-lookup-results');
const regField = document.getElementById('ac_reg');
if (!registration || registration.length < 4) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
aircraftLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - auto-populate the registration field
const aircraft = data[0];
regField.value = aircraft.registration || registration.toUpperCase();
resultsDiv.innerHTML = `
<div class="lookup-match">
${aircraft.registration || registration.toUpperCase()} - ${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}
</div>
`;
// Auto-fill aircraft type if not already filled
if (!document.getElementById('ac_type').value) {
document.getElementById('ac_type').value = aircraft.type_code || '';
}
} else if (data.length <= 10) {
// Multiple matches - show as clickable list
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(aircraft => `
<div class="aircraft-option" onclick="selectAircraft('${aircraft.registration || registration.toUpperCase()}', '${aircraft.type_code || ''}')">
<div class="aircraft-code">${aircraft.registration || registration.toUpperCase()}</div>
<div class="aircraft-details">${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}</div>
</div>
`).join('')}
</div>
`;
} else {
// Too many matches
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No aircraft found with this registration</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Aircraft lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
function selectAircraft(registration, typeCode) {
document.getElementById('ac_reg').value = registration;
// Always update the aircraft type when a specific aircraft is selected
document.getElementById('ac_type').value = typeCode;
document.getElementById('aircraft-lookup-results').innerHTML = '';
// Remove focus from the field to hide the dropdown
document.getElementById('ac_reg').blur();
}
// Clear aircraft lookup results when input loses focus
document.getElementById('ac_reg').addEventListener('blur', function() {
// Delay clearing to allow click events on options
setTimeout(() => {
document.getElementById('aircraft-lookup-results').innerHTML = '';
}, 150);
});
// Airport lookup functions
let arrivalAirportLookupTimeout;
async function handleArrivalAirportLookup(query) {
clearTimeout(arrivalAirportLookupTimeout);
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
const inputField = document.getElementById('in_from');
if (!query || query.length < 2) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
arrivalAirportLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - show as clickable option
const airport = data[0];
resultsDiv.innerHTML = `
<div class="aircraft-list">
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
</div>
`;
} else if (data.length <= 10) {
// Multiple matches - show as clickable list
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(airport => `
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
`).join('')}
</div>
`;
} else {
// Too many matches
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Arrival airport lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
let departureAirportLookupTimeout;
async function handleDepartureAirportLookup(query) {
clearTimeout(departureAirportLookupTimeout);
const resultsDiv = document.getElementById('departure-airport-lookup-results');
const inputField = document.getElementById('out_to');
if (!query || query.length < 2) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
departureAirportLookupTimeout = setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - show as clickable option
const airport = data[0];
resultsDiv.innerHTML = `
<div class="aircraft-list">
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
</div>
`;
} else if (data.length <= 10) {
// Multiple matches - show as clickable list
resultsDiv.innerHTML = `
<div class="aircraft-list">
${data.map(airport => `
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
`).join('')}
</div>
`;
} else {
// Too many matches
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
}
} else {
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
}
} else {
resultsDiv.innerHTML = '';
}
} catch (error) {
console.error('Departure airport lookup error:', error);
resultsDiv.innerHTML = '';
}
}, 500);
}
function selectAirport(fieldId, icaoCode) {
document.getElementById(fieldId).value = icaoCode;
// Clear the results
const resultsDivId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
document.getElementById(resultsDivId).innerHTML = '';
// Remove focus from the field to hide the dropdown
document.getElementById(fieldId).blur();
}
// Clear airport lookup results when input loses focus
document.getElementById('in_from').addEventListener('blur', function() {
setTimeout(() => {
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
}, 150);
});
document.getElementById('out_to').addEventListener('blur', function() {
setTimeout(() => {
document.getElementById('departure-airport-lookup-results').innerHTML = '';
}, 150);
});
// Form submission
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const pprData = {};
formData.forEach((value, key) => {
if (value.trim() !== '') {
if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = parseInt(value);
} else if (key === 'eta-date' && formData.get('eta-time')) {
// Combine date and time for ETA
const dateStr = formData.get('eta-date');
const timeStr = formData.get('eta-time');
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key === 'etd-date' && formData.get('etd-time')) {
// Combine date and time for ETD
const dateStr = formData.get('etd-date');
const timeStr = formData.get('etd-time');
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key !== 'eta-time' && key !== 'etd-time') {
// Skip the time fields as they're handled above
pprData[key] = value;
}
}
});
// Show loading
document.getElementById('loading').style.display = 'block';
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').textContent = 'Submitting...';
try {
const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(pprData)
});
if (response.ok) {
const result = await response.json();
console.log('PPR submitted successfully:', result);
// Hide form and show success message
document.getElementById('ppr-form').style.display = 'none';
document.getElementById('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!');
// Update iframe height after content change
setTimeout(sendHeightToParent, 100);
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
}
} catch (error) {
console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true);
// Update iframe height after showing error
setTimeout(sendHeightToParent, 100);
} finally {
// Hide loading
document.getElementById('loading').style.display = 'none';
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').textContent = 'Submit PPR Request';
}
});
// Set default date and time values
function setDefaultDateTime() {
const now = new Date();
// Round up to the next hour
const nextHour = new Date(now);
nextHour.setHours(now.getHours() + 1, 0, 0, 0);
// ETD is 2 hours after ETA
const etd = new Date(nextHour);
etd.setHours(nextHour.getHours() + 2);
// Format date and time for separate inputs
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
// Set ETA to next hour
document.getElementById('eta-date').value = formatDate(nextHour);
document.getElementById('eta-time').value = formatTime(nextHour);
// Set ETD to one hour after ETA
document.getElementById('etd-date').value = formatDate(etd);
document.getElementById('etd-time').value = formatTime(etd);
}
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
initializeTimeDropdowns();
setDefaultDateTime();
// Add event listeners to ETA fields to auto-update ETD
document.getElementById('eta-date').addEventListener('change', updateETDFromETA);
document.getElementById('eta-time').addEventListener('change', updateETDFromETA);
// Add event listeners to ETD fields to mark as manually edited
document.getElementById('etd-date').addEventListener('change', markETDAsManuallyEdited);
document.getElementById('etd-time').addEventListener('change', markETDAsManuallyEdited);
});
</script>
</body>
</html>

1386
web/reports.html Normal file

File diff suppressed because it is too large Load Diff

1
web/widgets/iframe.min.js vendored Normal file
View File

@@ -0,0 +1 @@
function initEmbed(e){window.addEventListener("message",function(t){if(t.data.type==="setHeight"){var n=document.getElementById(e);n&&(n.style.height=t.data.height+"px")}})}