Compare commits

...

10 Commits

Author SHA1 Message Date
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
26 changed files with 3614 additions and 164 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

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Database Configuration
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here
DB_USER=ppr_user
DB_PASSWORD=your_database_password_here
DB_NAME=ppr
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
# Web Configuration
WEB_PORT_EXTERNAL=8082
# phpMyAdmin Configuration
PMA_HOST=db
UPLOAD_LIMIT=50M
PMA_PORT_EXTERNAL=8083

102
README.md
View File

@@ -12,12 +12,16 @@ 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
@@ -33,7 +37,11 @@ cd nextgen
### 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
- **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: ppr_password123) - **Database**: localhost:3307 (user: ppr_user, password: ppr_password123)
- **phpMyAdmin**: http://localhost:8083
### 3. Default Login ### 3. Default Login
- **Username**: admin - **Username**: admin
@@ -45,20 +53,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
@@ -87,6 +143,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 +181,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:

138
backend/README_test_data.md Normal file
View File

@@ -0,0 +1,138 @@
# Test Data Population Script
This script generates and inserts 30 random PPR (Prior Permission Required) records into the database for testing purposes.
## Features
- **30 Random PPR Records**: Generates diverse test data with various aircraft, airports, and flight details
- **Real Aircraft Data**: Uses actual aircraft registration data from the `aircraft_data.csv` file
- **Real Airport Data**: Uses actual airport ICAO codes from the `airports_data_clean.csv` file
- **Random Status Distribution**: Includes NEW, CONFIRMED, LANDED, and DEPARTED statuses
- **Realistic Timestamps**: Generates ETA/ETD times with 15-minute intervals
- **Optional Fields**: Randomly includes email, phone, notes, and departure details
- **Duplicate Aircraft**: Some aircraft registrations appear multiple times for realistic testing
## Usage
### Prerequisites
- Database must be running and accessible
- Python environment with required dependencies installed
- CSV data files (`aircraft_data.csv` and `airports_data_clean.csv`) in the parent directory
### Running the Script
1. **Using the convenience script** (recommended):
```bash
cd /home/jamesp/docker/pprdev/nextgen
./populate_test_data.sh
```
2. **From within the Docker container**:
```bash
docker exec -it ppr-backend bash
cd /app
python populate_test_data.py
```
3. **From host machine** (if database is accessible):
```bash
cd /home/jamesp/docker/pprdev/nextgen/backend
python populate_test_data.py
```
## What Gets Generated
Each PPR record includes:
- **Aircraft**: Random registration, type, and callsign from real aircraft data
- **Route**: Random arrival airport (from Swansea), optional departure airport
- **Times**: ETA between 6 AM - 8 PM, ETD 1-4 hours later (if departing)
- **Passengers**: 1-4 POB for arrival, optional for departure
- **Contact**: Optional email and phone (70% and 50% chance respectively)
- **Fuel**: Random fuel type (100LL, JET A1, FULL) or none
- **Notes**: Optional flight purpose notes (various scenarios)
- **Status**: Random status distribution (NEW/CONFIRMED/LANDED/DEPARTED)
- **Timestamps**: Random submission dates within last 30 days
- **Public Token**: Auto-generated for edit/cancel functionality
### Aircraft Distribution
- Uses real aircraft registration data from `aircraft_data.csv`
- Includes various aircraft types (C172, PA28, BE36, R44, etc.)
- Some aircraft appear multiple times for realistic duplication
### Airport Distribution
- Uses real ICAO airport codes from `airports_data_clean.csv`
- Arrival airports are distributed globally
- Departure airports (when included) are different from arrival airports
### Data Quality Notes
- **Realistic Distribution**: Aircraft and airports are selected from actual aviation data
- **Time Constraints**: All times are within reasonable operating hours (6 AM - 8 PM)
- **Status Balance**: Roughly equal distribution across different PPR statuses
- **Contact Info**: Realistic email patterns and UK phone numbers
- **Flight Logic**: Departures only occur when a departure airport is specified
## Assumptions
- Database schema matches the PPRRecord model in `app/models/ppr.py`
- CSV files are present and properly formatted
- Database connection uses settings from `app/core/config.py`
- All required dependencies are installed in the Python environment
### Sample Output
```
Loading aircraft and airport data...
Loaded 520000 aircraft records
Loaded 43209 airport records
Generating and inserting 30 test PPR records...
Generated 10 records...
Generated 20 records...
Generated 30 records...
✅ Successfully inserted 30 test PPR records!
Total PPR records in database: 42
Status breakdown:
NEW: 8
CONFIRMED: 7
LANDED: 9
DEPARTED: 6
```
## Safety Notes
- **Non-destructive**: Only adds new records, doesn't modify existing data
- **Test Data Only**: All generated data is clearly identifiable as test data
- **Easy Cleanup**: Can be easily removed with SQL queries if needed
## Current Status ✅
The script is working correctly! It has successfully generated and inserted test data. As of the latest run:
- **Total PPR records in database**: 93
- **Status breakdown**:
- NEW: 19
- CONFIRMED: 22
- CANCELED: 1
- LANDED: 35
- DEPARTED: 16
## Troubleshooting
- **Database Connection**: Ensure the database container is running and accessible
- **CSV Files**: The script uses fallback data when CSV files aren't found (which is normal in containerized environments)
- **Dependencies**: Ensure all Python requirements are installed
- **Permissions**: Script needs database write permissions
## Recent Fixes
- ✅ Fixed SQLAlchemy 2.0 `func.count()` import issue
- ✅ Script now runs successfully and provides status breakdown
- ✅ Uses fallback aircraft/airport data when CSV files aren't accessible
## Cleanup (if needed)
To remove all test data:
```sql
DELETE FROM submitted WHERE submitted_dt > '2025-01-01'; -- Adjust date as needed
```

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

@@ -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,

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

@@ -2,6 +2,7 @@ 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.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 +12,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,
@@ -67,7 +71,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()

View File

@@ -42,6 +42,7 @@ class PPRRecord(Base):
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)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp()) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
public_token = Column(String(128), nullable=True, unique=True)
class User(Base): class User(Base):

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>

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

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

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,23 +21,24 @@ 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}
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
networks: networks:
- ppr_network - private_network
- public_network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Redis for caching (optional for now) # Redis for caching (optional for now)
@@ -52,10 +46,8 @@ services:
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 # Nginx web server for public frontend
web: web:
@@ -63,14 +55,14 @@ services:
container_name: ppr_nextgen_web container_name: ppr_nextgen_web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8082:80" # Public web interface - "${WEB_PORT_EXTERNAL}:80" # Public web interface
volumes: volumes:
- ./web:/usr/share/nginx/html - ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf - ./nginx.conf:/etc/nginx/nginx.conf
depends_on: depends_on:
- api - api
networks: networks:
- ppr_network - public_network
# phpMyAdmin for database management # phpMyAdmin for database management
phpmyadmin: phpmyadmin:
@@ -78,21 +70,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

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!"

View File

@@ -17,22 +17,32 @@
color: #333; color: #333;
} }
.header { .top-bar {
background: linear-gradient(135deg, #2c3e50, #3498db); background: linear-gradient(135deg, #2c3e50, #3498db);
color: white; color: white;
padding: 1rem 2rem; padding: 0.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: 0 2px 10px rgba(0,0,0,0.1);
} }
.header h1 { .title h1 {
margin: 0; margin: 0;
font-size: 1.8rem; font-size: 1.5rem;
} }
.header .user-info { .menu-buttons {
float: right; display: flex;
gap: 1rem;
align-items: center;
}
.top-bar .user-info {
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.9; opacity: 0.9;
display: flex;
align-items: center;
} }
.container { .container {
@@ -41,26 +51,7 @@
padding: 2rem; padding: 2rem;
} }
.top-menu {
background: #2c3e50;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.menu-left {
display: flex;
gap: 1rem;
align-items: center;
}
.menu-right {
display: flex;
gap: 1rem;
align-items: center;
}
.btn { .btn {
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
@@ -563,29 +554,28 @@
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="top-bar">
<h1>✈️ PPR Administration</h1> <div class="title">
<div class="user-info"> <h1>✈️ Swansea PPR</h1>
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div> </div>
<div style="clear: both;"></div> <div class="menu-buttons">
</div>
<div class="top-menu">
<div class="menu-left">
<button class="btn btn-success" onclick="openNewPPRModal()"> <button class="btn btn-success" onclick="openNewPPRModal()">
New PPR Entry New PPR Entry
</button> </button>
<button class="btn btn-primary" onclick="window.open('reports.html', '_blank')">
📊 Reports
</button>
<button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;"> <button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;">
👥 User Management 👥 User Management
</button> </button>
</div>
<div class="menu-right">
<button class="btn btn-primary" onclick="loadPPRs()"> <button class="btn btn-primary" onclick="loadPPRs()">
🔄 Refresh 🔄 Refresh
</button> </button>
</div> </div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div> </div>
<div class="container"> <div class="container">
@@ -702,10 +692,10 @@
✓ Confirm ✓ Confirm
</button> </button>
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')"> <button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
🛬 Landed 🛬 Land
</button> </button>
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')"> <button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
🛫 Departed 🛫 Depart
</button> </button>
<button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')"> <button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')">
❌ Cancel ❌ Cancel
@@ -723,11 +713,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ac_type">Aircraft Type *</label> <label for="ac_type">Aircraft Type *</label>
<input type="text" id="ac_type" name="ac_type" required> <input type="text" id="ac_type" name="ac_type" required tabindex="-1">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ac_call">Callsign</label> <label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration"> <input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="captain">Captain *</label> <label for="captain">Captain *</label>
@@ -740,7 +730,12 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="eta">ETA (Local Time) *</label> <label for="eta">ETA (Local Time) *</label>
<input type="datetime-local" id="eta" name="eta" required> <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>
<div class="form-group"> <div class="form-group">
<label for="pob_in">POB Inbound *</label> <label for="pob_in">POB Inbound *</label>
@@ -748,7 +743,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fuel">Fuel Required</label> <label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel"> <select id="fuel" name="fuel" tabindex="-1">
<option value="">None</option> <option value="">None</option>
<option value="100LL">100LL</option> <option value="100LL">100LL</option>
<option value="JET A1">JET A1</option> <option value="JET A1">JET A1</option>
@@ -757,28 +752,33 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="out_to">Departing To</label> <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)"> <input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
<div id="departure-airport-lookup-results"></div> <div id="departure-airport-lookup-results"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="etd">ETD (Local Time)</label> <label for="etd">ETD (Local Time)</label>
<input type="datetime-local" id="etd" name="etd"> <div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="pob_out">POB Outbound</label> <label for="pob_out">POB Outbound</label>
<input type="number" id="pob_out" name="pob_out" min="1"> <input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email"> <input type="email" id="email" name="email" tabindex="-1">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="phone">Phone</label> <label for="phone">Phone</label>
<input type="tel" id="phone" name="phone"> <input type="tel" id="phone" name="phone" tabindex="-1">
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="notes">Notes</label> <label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="3"></textarea> <textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
</div> </div>
</div> </div>
@@ -998,12 +998,27 @@
}, 3000); }, 3000);
} }
// Initialize the application // Initialize time dropdowns
document.addEventListener('DOMContentLoaded', function() { function initializeTimeDropdowns() {
initializeAuth(); const timeSelects = ['eta-time', 'etd-time'];
setupLoginForm();
setupKeyboardShortcuts(); 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);
}
}
});
}
// Authentication management // Authentication management
async function initializeAuth() { async function initializeAuth() {
@@ -1256,40 +1271,57 @@
document.getElementById('departures-loading').style.display = 'none'; document.getElementById('departures-loading').style.display = 'none';
} }
function displayArrivals(arrivals) { // ICAO code to airport name cache
const airportNameCache = {};
async function getAirportDisplay(code) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${airportNameCache[code]}</span>`;
try {
const resp = await authenticatedFetch(`/api/v1/airport/lookup/${code}`);
if (resp.ok) {
const data = await resp.json();
if (data && data.length && data[0].name) {
airportNameCache[code] = data[0].name;
return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${data[0].name}</span>`;
}
}
} catch {}
return code;
}
async function displayArrivals(arrivals) {
const tbody = document.getElementById('arrivals-table-body'); const tbody = document.getElementById('arrivals-table-body');
const recordCount = document.getElementById('arrivals-count'); const recordCount = document.getElementById('arrivals-count');
recordCount.textContent = arrivals.length; recordCount.textContent = arrivals.length;
if (arrivals.length === 0) { if (arrivals.length === 0) {
document.getElementById('arrivals-no-data').style.display = 'block'; document.getElementById('arrivals-no-data').style.display = 'block';
return; return;
} }
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block'; document.getElementById('arrivals-table-content').style.display = 'block';
for (const ppr of arrivals) {
arrivals.forEach(ppr => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id); row.onclick = () => openPPRModal(ppr.id);
// Create notes indicator if notes exist // Create notes indicator if notes exist
const notesIndicator = ppr.notes && ppr.notes.trim() ? const notesIndicator = ppr.notes && ppr.notes.trim() ?
`<span class="notes-tooltip"> `<span class="notes-tooltip">
<span class="notes-indicator">📝</span> <span class="notes-indicator">📝</span>
<span class="tooltip-text">${ppr.notes}</span> <span class="tooltip-text">${ppr.notes}</span>
</span>` : ''; </span>` : '';
// Display callsign as main item if present, registration below; otherwise show registration // Display callsign as main item if present, registration below; otherwise show registration
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ? const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` : `<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
`<strong>${ppr.ac_reg}</strong>`; `<strong>${ppr.ac_reg}</strong>`;
// Lookup airport name for in_from
let fromDisplay = ppr.in_from;
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
fromDisplay = await getAirportDisplay(ppr.in_from);
}
row.innerHTML = ` row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td> <td>${aircraftDisplay}${notesIndicator}</td>
<td>${ppr.ac_type}</td> <td>${ppr.ac_type}</td>
<td>${ppr.in_from}</td> <td>${fromDisplay}</td>
<td>${formatTimeOnly(ppr.eta)}</td> <td>${formatTimeOnly(ppr.eta)}</td>
<td>${ppr.pob_in}</td> <td>${ppr.pob_in}</td>
<td>${ppr.fuel || '-'}</td> <td>${ppr.fuel || '-'}</td>
@@ -1302,45 +1334,42 @@
</button> </button>
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); }
} }
function displayDepartures(departures) { async function displayDepartures(departures) {
const tbody = document.getElementById('departures-table-body'); const tbody = document.getElementById('departures-table-body');
const recordCount = document.getElementById('departures-count'); const recordCount = document.getElementById('departures-count');
recordCount.textContent = departures.length; recordCount.textContent = departures.length;
if (departures.length === 0) { if (departures.length === 0) {
document.getElementById('departures-no-data').style.display = 'block'; document.getElementById('departures-no-data').style.display = 'block';
return; return;
} }
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block'; document.getElementById('departures-table-content').style.display = 'block';
for (const ppr of departures) {
departures.forEach(ppr => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id); row.onclick = () => openPPRModal(ppr.id);
// Create notes indicator if notes exist // Create notes indicator if notes exist
const notesIndicator = ppr.notes && ppr.notes.trim() ? const notesIndicator = ppr.notes && ppr.notes.trim() ?
`<span class="notes-tooltip"> `<span class="notes-tooltip">
<span class="notes-indicator">📝</span> <span class="notes-indicator">📝</span>
<span class="tooltip-text">${ppr.notes}</span> <span class="tooltip-text">${ppr.notes}</span>
</span>` : ''; </span>` : '';
// Display callsign as main item if present, registration below; otherwise show registration // Display callsign as main item if present, registration below; otherwise show registration
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ? const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` : `<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
`<strong>${ppr.ac_reg}</strong>`; `<strong>${ppr.ac_reg}</strong>`;
// Lookup airport name for out_to
let toDisplay = ppr.out_to || '-';
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
toDisplay = await getAirportDisplay(ppr.out_to);
}
row.innerHTML = ` row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td> <td>${aircraftDisplay}${notesIndicator}</td>
<td>${ppr.ac_type}</td> <td>${ppr.ac_type}</td>
<td>${ppr.out_to || '-'}</td> <td>${toDisplay}</td>
<td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td> <td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td>
<td>${ppr.pob_out || ppr.pob_in}</td> <td>${ppr.pob_out || ppr.pob_in}</td>
<td>${ppr.fuel || '-'}</td> <td>${ppr.fuel || '-'}</td>
@@ -1354,15 +1383,20 @@
</button> </button>
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); }
} }
function formatTimeOnly(dateStr) { function formatTimeOnly(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
// Ensure the datetime string is treated as UTC // Ensure the datetime string is treated as UTC
const utcDateStr = dateStr.includes('Z') ? dateStr : dateStr + 'Z'; let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr); const date = new Date(utcDateStr);
return date.toISOString().slice(11, 16); return date.toISOString().slice(11, 16);
} }
@@ -1370,7 +1404,13 @@
function formatDateTime(dateStr) { function formatDateTime(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
// Ensure the datetime string is treated as UTC // Ensure the datetime string is treated as UTC
const utcDateStr = dateStr.includes('Z') ? dateStr : dateStr + 'Z'; let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr); const date = new Date(utcDateStr);
return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16); return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16);
} }
@@ -1393,18 +1433,24 @@
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
// Format as local datetime-local value // Format date and time for separate inputs
function formatLocalDateTime(date) { function formatDate(date) {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0'); return `${year}-${month}-${day}`;
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
} }
document.getElementById('eta').value = formatLocalDateTime(eta); function formatTime(date) {
document.getElementById('etd').value = etd ? formatLocalDateTime(etd) : ''; const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(Math.ceil(date.getMinutes() / 15) * 15 % 60).padStart(2, '0'); // Round up to next 15-minute interval
return `${hours}:${minutes}`;
}
document.getElementById('eta-date').value = formatDate(eta);
document.getElementById('eta-time').value = formatTime(eta);
document.getElementById('etd-date').value = formatDate(etd);
document.getElementById('etd-time').value = formatTime(etd);
// Clear aircraft lookup results // Clear aircraft lookup results
clearAircraftLookup(); clearAircraftLookup();
@@ -1437,6 +1483,28 @@
const ppr = await response.json(); const ppr = await response.json();
populateForm(ppr); populateForm(ppr);
// Show/hide quick action buttons based on current status
if (ppr.status === 'NEW') {
document.getElementById('btn-confirm').style.display = 'inline-block';
document.getElementById('btn-landed').style.display = 'none';
document.getElementById('btn-departed').style.display = 'none';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else if (ppr.status === 'CONFIRMED') {
document.getElementById('btn-confirm').style.display = 'none';
document.getElementById('btn-landed').style.display = 'inline-block';
document.getElementById('btn-departed').style.display = 'none';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else if (ppr.status === 'LANDED') {
document.getElementById('btn-confirm').style.display = 'none';
document.getElementById('btn-landed').style.display = 'none';
document.getElementById('btn-departed').style.display = 'inline-block';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else {
// DEPARTED, CANCELED, DELETED - hide all quick actions
document.querySelector('.quick-actions').style.display = 'none';
}
await loadJournal(pprId); // Always load journal when opening a PPR await loadJournal(pprId); // Always load journal when opening a PPR
document.getElementById('pprModal').style.display = 'block'; document.getElementById('pprModal').style.display = 'block';
@@ -1447,24 +1515,55 @@
} }
function populateForm(ppr) { function populateForm(ppr) {
console.log('populateForm called with:', ppr);
Object.keys(ppr).forEach(key => { Object.keys(ppr).forEach(key => {
const field = document.getElementById(key); if (key === 'eta' || key === 'etd') {
if (field) { if (ppr[key]) {
if (key === 'eta' || key === 'etd') { console.log(`Processing ${key}:`, ppr[key]);
if (ppr[key]) { // ppr[key] is UTC datetime string from API (naive, assume UTC)
// ppr[key] is UTC datetime string from API (naive, assume UTC) let utcDateStr = ppr[key];
const utcDateStr = ppr[key].includes('Z') ? ppr[key] : ppr[key] + 'Z'; if (!utcDateStr.includes('T')) {
const date = new Date(utcDateStr); // Now correctly parsed as UTC utcDateStr = utcDateStr.replace(' ', 'T');
// Format as local time for datetime-local input }
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr); // Now correctly parsed as UTC
console.log(`Parsed date for ${key}:`, date);
// Split into date and time components for separate inputs
const dateField = document.getElementById(`${key}-date`);
const timeField = document.getElementById(`${key}-time`);
if (dateField && timeField) {
// Format date
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const dateValue = `${year}-${month}-${day}`;
dateField.value = dateValue;
console.log(`Set ${key}-date to:`, dateValue);
// Format time (round to nearest 15-minute interval)
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const rawMinutes = date.getMinutes();
field.value = `${year}-${month}-${day}T${hours}:${minutes}`; const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
const minutes = String(roundedMinutes).padStart(2, '0');
const timeValue = `${hours}:${minutes}`;
timeField.value = timeValue;
console.log(`Set ${key}-time to:`, timeValue, `(from ${rawMinutes} minutes)`);
} else {
console.log(`Date/time fields not found for ${key}: dateField=${dateField}, timeField=${timeField}`);
} }
} else { } else {
console.log(`${key} is empty`);
}
} else {
const field = document.getElementById(key);
if (field) {
field.value = ppr[key] || ''; field.value = ppr[key] || '';
} else {
console.log(`Field not found for key: ${key}`);
} }
} }
}); });
@@ -1588,6 +1687,7 @@
closeTimestampModal(); closeTimestampModal();
loadPPRs(); // Refresh both tables loadPPRs(); // Refresh both tables
showNotification(`Status updated to ${updatedStatus}`); showNotification(`Status updated to ${updatedStatus}`);
closePPRModal(); // Close PPR modal after successful status update
} catch (error) { } catch (error) {
console.error('Error updating status:', error); console.error('Error updating status:', error);
showNotification(`Error updating status: ${error.message}`, true); showNotification(`Error updating status: ${error.message}`, true);
@@ -1607,10 +1707,18 @@
if (key !== 'id' && value.trim() !== '') { if (key !== 'id' && value.trim() !== '') {
if (key === 'pob_in' || key === 'pob_out') { if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = parseInt(value); pprData[key] = parseInt(value);
} else if (key === 'eta' || key === 'etd') { } else if (key === 'eta-date' && formData.get('eta-time')) {
// Convert local datetime-local to UTC ISO string // Combine date and time for ETA
pprData[key] = new Date(value).toISOString(); const dateStr = formData.get('eta-date');
} else { 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; pprData[key] = value;
} }
} }
@@ -1656,6 +1764,13 @@
async function updateStatus(status) { async function updateStatus(status) {
if (!currentPPRId || !accessToken) return; if (!currentPPRId || !accessToken) return;
// Show confirmation for cancel actions
if (status === 'CANCELED') {
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
return;
}
}
try { try {
const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, { const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, {
method: 'PATCH', method: 'PATCH',
@@ -1673,6 +1788,7 @@
await loadJournal(currentPPRId); // Refresh journal await loadJournal(currentPPRId); // Refresh journal
loadPPRs(); // Refresh both tables loadPPRs(); // Refresh both tables
showNotification(`Status updated to ${status}`); showNotification(`Status updated to ${status}`);
closePPRModal(); // Close modal after successful status update
} catch (error) { } catch (error) {
console.error('Error updating status:', error); console.error('Error updating status:', error);
showNotification('Error updating status', true); showNotification('Error updating status', true);
@@ -2285,6 +2401,14 @@
document.getElementById('out_to').value = icaoCode; document.getElementById('out_to').value = icaoCode;
clearDepartureAirportLookup(); clearDepartureAirportLookup();
} }
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupLoginForm();
setupKeyboardShortcuts();
initializeTimeDropdowns(); // Initialize time dropdowns
initializeAuth(); // Start authentication process
});
</script> </script>
</body> </body>
</html> </html>

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>

View File

@@ -252,13 +252,40 @@
return `<span class="status ${status.toLowerCase()}">${status}</span>`; return `<span class="status ${status.toLowerCase()}">${status}</span>`;
} }
// Create PPR item HTML // ICAO code to airport name cache
function createPPRItem(ppr) { const airportNameCache = {};
async function getAirportDisplay(code) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${airportNameCache[code]}</span>`;
try {
const resp = await fetch(`/api/v1/airport/lookup/${code}`);
if (resp.ok) {
const data = await resp.json();
if (data && data.length && data[0].name) {
airportNameCache[code] = data[0].name;
return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${data[0].name}</span>`;
}
}
} catch {}
return code;
}
// Create PPR item HTML (async)
async function createPPRItem(ppr) {
// Display callsign as main item if present, registration below; otherwise show registration // Display callsign as main item if present, registration below; otherwise show registration
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ? const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`${ppr.ac_call}<br><span style="font-size: 0.85em; color: #888; font-style: italic;">${ppr.ac_reg}</span>` : `${ppr.ac_call}<br><span style="font-size: 0.85em; color: #888; font-style: italic;">${ppr.ac_reg}</span>` :
ppr.ac_reg; ppr.ac_reg;
// Lookup airport name for in_from/out_to
let fromDisplay = ppr.in_from;
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
fromDisplay = await getAirportDisplay(ppr.in_from);
}
let toDisplay = ppr.out_to;
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
toDisplay = await getAirportDisplay(ppr.out_to);
}
return ` return `
<div class="ppr-item"> <div class="ppr-item">
<div class="ppr-field"> <div class="ppr-field">
@@ -269,6 +296,14 @@
<strong>Type</strong> <strong>Type</strong>
<span>${ppr.ac_type}</span> <span>${ppr.ac_type}</span>
</div> </div>
<div class="ppr-field">
<strong>From</strong>
<span>${fromDisplay || '-'}</span>
</div>
<div class="ppr-field">
<strong>To</strong>
<span>${toDisplay || '-'}</span>
</div>
<div class="ppr-field"> <div class="ppr-field">
<strong>Time</strong> <strong>Time</strong>
<span>${formatDateTime(ppr.eta || ppr.etd)}</span> <span>${formatDateTime(ppr.eta || ppr.etd)}</span>
@@ -298,19 +333,21 @@
try { try {
const response = await fetch('/api/v1/public/arrivals'); const response = await fetch('/api/v1/public/arrivals');
const arrivals = await response.json(); const arrivals = await response.json();
const loadingEl = document.getElementById('arrivals-loading'); const loadingEl = document.getElementById('arrivals-loading');
const listEl = document.getElementById('arrivals-list'); const listEl = document.getElementById('arrivals-list');
const countEl = document.getElementById('arrivals-count'); const countEl = document.getElementById('arrivals-count');
loadingEl.style.display = 'none'; loadingEl.style.display = 'none';
listEl.style.display = 'block'; listEl.style.display = 'block';
if (arrivals.length === 0) { if (arrivals.length === 0) {
listEl.innerHTML = createNoDataMessage('arrivals'); listEl.innerHTML = createNoDataMessage('arrivals');
countEl.textContent = '0 flights'; countEl.textContent = '0 flights';
} else { } else {
listEl.innerHTML = arrivals.map(createPPRItem).join(''); // Render each item async
const htmlArr = [];
for (const ppr of arrivals) {
htmlArr.push(await createPPRItem(ppr));
}
listEl.innerHTML = htmlArr.join('');
countEl.textContent = `${arrivals.length} flight${arrivals.length !== 1 ? 's' : ''}`; countEl.textContent = `${arrivals.length} flight${arrivals.length !== 1 ? 's' : ''}`;
} }
} catch (error) { } catch (error) {
@@ -330,19 +367,21 @@
try { try {
const response = await fetch('/api/v1/public/departures'); const response = await fetch('/api/v1/public/departures');
const departures = await response.json(); const departures = await response.json();
const loadingEl = document.getElementById('departures-loading'); const loadingEl = document.getElementById('departures-loading');
const listEl = document.getElementById('departures-list'); const listEl = document.getElementById('departures-list');
const countEl = document.getElementById('departures-count'); const countEl = document.getElementById('departures-count');
loadingEl.style.display = 'none'; loadingEl.style.display = 'none';
listEl.style.display = 'block'; listEl.style.display = 'block';
if (departures.length === 0) { if (departures.length === 0) {
listEl.innerHTML = createNoDataMessage('departures'); listEl.innerHTML = createNoDataMessage('departures');
countEl.textContent = '0 flights'; countEl.textContent = '0 flights';
} else { } else {
listEl.innerHTML = departures.map(createPPRItem).join(''); // Render each item async
const htmlArr = [];
for (const ppr of departures) {
htmlArr.push(await createPPRItem(ppr));
}
listEl.innerHTML = htmlArr.join('');
countEl.textContent = `${departures.length} flight${departures.length !== 1 ? 's' : ''}`; countEl.textContent = `${departures.length} flight${departures.length !== 1 ? 's' : ''}`;
} }
} catch (error) { } catch (error) {

694
web/ppr.html Normal file
View File

@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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-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;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✈️ Swansea Airport PPR Request</h1>
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</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="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 Successfully!</h3>
<p>Your Prior Permission Required request has been submitted and will be reviewed by airport operations. You will receive confirmation via email if provided.</p>
<p><strong>Please note:</strong> This is not confirmation of approval. Airport operations will contact you if additional information is required.</p>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<script>
// 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');
}, 10);
// Hide after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
}, 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/v1/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/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
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/v1/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!');
} 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);
} finally {
// Hide loading
document.getElementById('loading').style.display = 'none';
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').textContent = 'Submit PPR Request';
}
});
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
initializeTimeDropdowns();
});
</script>
</body>
</html>

732
web/reports.html Normal file
View File

@@ -0,0 +1,732 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPR Reports - 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;
}
.top-bar {
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.menu-buttons {
display: flex;
gap: 1rem;
align-items: center;
}
.top-bar .user-info {
font-size: 0.9rem;
opacity: 0.9;
display: flex;
align-items: center;
}
.container {
max-width: 1600px;
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-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;
}
.filters-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #555;
}
.filter-group input, .filter-group select {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.filter-actions {
display: flex;
gap: 1rem;
align-items: end;
}
.reports-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;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-info {
font-size: 0.9rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 3rem;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
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: 0.85rem;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
white-space: nowrap;
}
tbody tr {
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.status {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
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;
}
.export-buttons {
display: flex;
gap: 0.5rem;
}
.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;
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filter-actions {
flex-direction: column;
align-items: stretch;
}
.table-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.export-buttons {
width: 100%;
justify-content: center;
}
th, td {
padding: 0.3rem;
font-size: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
}
/* Scrollable table for mobile */
.table-container {
overflow-x: auto;
max-width: 100%;
}
.table-container table {
min-width: 1200px;
}
</style>
</head>
<body>
<div class="top-bar">
<div class="title">
<h1>📊 PPR Reports</h1>
</div>
<div class="menu-buttons">
<button class="btn btn-secondary" onclick="window.location.href='admin.html'">
← Back to Admin
</button>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div>
<div class="container">
<!-- Filters Section -->
<div class="filters-section">
<div class="filters-grid">
<div class="filter-group">
<label for="date-from">Date From:</label>
<input type="date" id="date-from">
</div>
<div class="filter-group">
<label for="date-to">Date To:</label>
<input type="date" id="date-to">
</div>
<div class="filter-group">
<label for="status-filter">Status:</label>
<select id="status-filter">
<option value="">All Statuses</option>
<option value="NEW">New</option>
<option value="CONFIRMED">Confirmed</option>
<option value="LANDED">Landed</option>
<option value="DEPARTED">Departed</option>
<option value="CANCELED">Canceled</option>
<option value="DELETED">Deleted</option>
</select>
</div>
<div class="filter-group">
<label for="search-input">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
</div>
<div class="filter-actions">
<button class="btn btn-primary" onclick="loadReports()">
🔍 Search
</button>
<button class="btn btn-secondary" onclick="clearFilters()">
🗑️ Clear
</button>
</div>
</div>
</div>
<!-- Reports Table -->
<div class="reports-table">
<div class="table-header">
<div>
<strong>PPR Records</strong>
<div class="table-info" id="table-info">Loading...</div>
</div>
<div class="export-buttons">
<button class="btn btn-success" onclick="exportToCSV()">
📊 Export CSV
</button>
<button class="btn btn-success" onclick="exportToXLS()">
📋 Export XLS
</button>
</div>
</div>
<div id="reports-loading" class="loading">
<div class="spinner"></div>
Loading reports...
</div>
<div id="reports-table-content" style="display: none;">
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Aircraft</th>
<th>Type</th>
<th>Callsign</th>
<th>Captain</th>
<th>From</th>
<th>ETA</th>
<th>POB In</th>
<th>To</th>
<th>ETD</th>
<th>POB Out</th>
<th>Fuel</th>
<th>Landed</th>
<th>Departed</th>
<th>Email</th>
<th>Phone</th>
<th>Notes</th>
<th>Submitted</th>
<th>Created By</th>
</tr>
</thead>
<tbody id="reports-table-body">
</tbody>
</table>
</div>
</div>
<div id="reports-no-data" class="no-data" style="display: none;">
<h3>No PPR records found</h3>
<p>No records match your current filters.</p>
</div>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<script>
let currentUser = null;
let accessToken = null;
let currentPPRs = []; // Store current results for export
// Initialize the page
async function initializePage() {
await initializeAuth();
setupDefaultDateRange();
await loadReports();
}
// Set default date range to current month
function setupDefaultDateRange() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
}
// Authentication management
async function initializeAuth() {
const cachedToken = localStorage.getItem('ppr_access_token');
const cachedUser = localStorage.getItem('ppr_username');
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
if (cachedToken && cachedUser && tokenExpiry) {
const now = new Date().getTime();
if (now < parseInt(tokenExpiry)) {
accessToken = cachedToken;
currentUser = cachedUser;
document.getElementById('current-user').textContent = cachedUser;
return;
}
}
// No valid cached token, redirect to admin
window.location.href = 'admin.html';
}
function logout() {
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
accessToken = null;
currentUser = null;
window.location.href = 'admin.html';
}
// Enhanced fetch wrapper with token expiry handling
async function authenticatedFetch(url, options = {}) {
if (!accessToken) {
window.location.href = 'admin.html';
throw new Error('No access token available');
}
const headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
const response = await fetch(url, {
...options,
headers
});
if (response.status === 401) {
logout();
throw new Error('Session expired. Please log in again.');
}
return response;
}
// Load reports data
async function loadReports() {
document.getElementById('reports-loading').style.display = 'block';
document.getElementById('reports-table-content').style.display = 'none';
document.getElementById('reports-no-data').style.display = 'none';
try {
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
const status = document.getElementById('status-filter').value;
const search = document.getElementById('search-input').value.trim();
let url = `/api/v1/pprs/?limit=10000`; // Large limit for reports
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
if (status) url += `&status=${status}`;
const response = await authenticatedFetch(url);
if (!response.ok) {
throw new Error('Failed to fetch PPR records');
}
let pprs = await response.json();
// Apply client-side search filtering
if (search) {
const searchLower = search.toLowerCase();
pprs = pprs.filter(ppr =>
(ppr.ac_reg && ppr.ac_reg.toLowerCase().includes(searchLower)) ||
(ppr.ac_call && ppr.ac_call.toLowerCase().includes(searchLower)) ||
(ppr.captain && ppr.captain.toLowerCase().includes(searchLower)) ||
(ppr.in_from && ppr.in_from.toLowerCase().includes(searchLower)) ||
(ppr.out_to && ppr.out_to.toLowerCase().includes(searchLower))
);
}
currentPPRs = pprs; // Store for export
displayReports(pprs);
} catch (error) {
console.error('Error loading reports:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading reports', true);
}
}
document.getElementById('reports-loading').style.display = 'none';
}
// Display reports in table
async function displayReports(pprs) {
const tbody = document.getElementById('reports-table-body');
const tableInfo = document.getElementById('table-info');
tableInfo.textContent = `${pprs.length} records found`;
if (pprs.length === 0) {
document.getElementById('reports-no-data').style.display = 'block';
return;
}
tbody.innerHTML = '';
document.getElementById('reports-table-content').style.display = 'block';
for (const ppr of pprs) {
const row = document.createElement('tr');
// Format dates
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
const etd = ppr.etd ? formatDateTime(ppr.etd) : '-';
const landed = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
const departed = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
// Status styling
const statusClass = `status ${ppr.status.toLowerCase()}`;
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
row.innerHTML = `
<td>${ppr.id}</td>
<td><span class="${statusClass}">${statusText}</span></td>
<td>${ppr.ac_reg}</td>
<td>${ppr.ac_type}</td>
<td>${ppr.ac_call || '-'}</td>
<td>${ppr.captain}</td>
<td>${ppr.in_from}</td>
<td>${eta}</td>
<td>${ppr.pob_in}</td>
<td>${ppr.out_to || '-'}</td>
<td>${etd}</td>
<td>${ppr.pob_out || '-'}</td>
<td>${ppr.fuel || '-'}</td>
<td>${landed}</td>
<td>${departed}</td>
<td>${ppr.email || '-'}</td>
<td>${ppr.phone || '-'}</td>
<td>${ppr.notes || '-'}</td>
<td>${submitted}</td>
<td>${ppr.created_by || '-'}</td>
`;
tbody.appendChild(row);
}
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
return date.toISOString().slice(0, 16).replace('T', ' ');
}
// Clear filters
function clearFilters() {
document.getElementById('status-filter').value = '';
document.getElementById('search-input').value = '';
setupDefaultDateRange();
loadReports();
}
// Export functions
function exportToCSV() {
if (currentPPRs.length === 0) {
showNotification('No data to export', true);
return;
}
const headers = [
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
'From', 'ETA', 'POB In', 'To', 'ETD', 'POB Out', 'Fuel',
'Landed', 'Departed', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
];
const csvData = currentPPRs.map(ppr => [
ppr.id,
ppr.status,
ppr.ac_reg,
ppr.ac_type,
ppr.ac_call || '',
ppr.captain,
ppr.in_from,
ppr.eta ? formatDateTime(ppr.eta) : '',
ppr.pob_in,
ppr.out_to || '',
ppr.etd ? formatDateTime(ppr.etd) : '',
ppr.pob_out || '',
ppr.fuel || '',
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
ppr.email || '',
ppr.phone || '',
ppr.notes || '',
ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '',
ppr.created_by || ''
]);
downloadCSV(headers, csvData, 'ppr_reports.csv');
}
function exportToXLS() {
if (currentPPRs.length === 0) {
showNotification('No data to export', true);
return;
}
// For XLS export, we'll create a CSV that Excel can open
// In a production environment, you'd want to use a proper XLS library
exportToCSV();
showNotification('XLS export uses CSV format (compatible with Excel)');
}
function downloadCSV(headers, data, filename) {
const csvContent = [
headers.join(','),
...data.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showNotification(`Exported ${data.length} records to ${filename}`);
}
// 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');
}, 3000);
}
// Handle Enter key in search input
document.getElementById('search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
loadReports();
}
});
// Initialize when page loads
document.addEventListener('DOMContentLoaded', initializePage);
</script>
</body>
</html>