Compare commits
67 Commits
ef273c0c5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 211db514dd | |||
| 24971ac5fc | |||
|
|
a1a5f90f00 | ||
| 97995fa58e | |||
| bcd582aee5 | |||
| dc6b551325 | |||
| ac29b6e929 | |||
| 0149f45893 | |||
| 63564b54dd | |||
| 3ab9a6e04c | |||
| b46a88d471 | |||
| 658d4c4ff8 | |||
| a43ab34a8f | |||
| dee5d38b58 | |||
| ee311cc120 | |||
|
|
e63fdc74ec | ||
| a8c0a37b7e | |||
| c92f838489 | |||
|
|
8513a7bb0f | ||
| d183678282 | |||
| a2682314c9 | |||
| f3eb83665f | |||
| f572fb75f5 | |||
| f65c54109e | |||
| d53ddff4be | |||
| 2d4f1467de | |||
| 65eb3272f2 | |||
| 6209c7acce | |||
| d7eefdb652 | |||
| 98d0e3cfd7 | |||
| d2e7d3c3dd | |||
| ea35de5eb5 | |||
| 97517777df | |||
| ab3319af06 | |||
| 32ad7a793a | |||
| dbb285fa20 | |||
| f7467690e4 | |||
| 1d1c504f91 | |||
| 0aeed2268a | |||
| 56e4ab6e3e | |||
| ee1b42442e | |||
| cc5697eaa0 | |||
| 9cfd88d848 | |||
|
|
7efc2ef37a | ||
|
|
5f2aa82e36 | ||
|
|
e8bd30aadc | ||
|
|
bd1200f377 | ||
|
|
f4b69aace0 | ||
|
|
86f1dc65f4 | ||
|
|
169c3af29b | ||
|
|
11f7390694 | ||
|
|
39d5c2c9e2 | ||
|
|
4d71d59d90 | ||
|
|
3780b3cf2f | ||
|
|
b6ad496cf0 | ||
|
|
d33ad725cb | ||
|
|
b2a6545ace | ||
|
|
77b5080bbd | ||
|
|
7643c179fb | ||
|
|
023c238cee | ||
|
|
6e760a3e96 | ||
|
|
d5f05941c9 | ||
|
|
91e820b9a8 | ||
|
|
b6e32eccad | ||
|
|
9d77e11627 | ||
|
|
1223d9e9f9 | ||
|
|
41c7bb352a |
@@ -4,6 +4,16 @@ 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`
|
||||||
- Authentication: Bearer token required for most endpoints
|
- Authentication: Bearer token required for most endpoints
|
||||||
@@ -145,6 +155,58 @@ curl -X POST "http://localhost:8001/api/v1/pprs/" \
|
|||||||
|
|
||||||
**Endpoint:** `DELETE /api/v1/pprs/{ppr_id}` (soft delete - sets status to DELETED)
|
**Endpoint:** `DELETE /api/v1/pprs/{ppr_id}` (soft delete - sets status to DELETED)
|
||||||
|
|
||||||
|
## User Management Endpoints (Admin Only)
|
||||||
|
|
||||||
|
### List Users
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/auth/users`
|
||||||
|
|
||||||
|
### Create User
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/auth/users`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "securepassword",
|
||||||
|
"role": "OPERATOR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Roles:**
|
||||||
|
- `ADMINISTRATOR`: Full access including user management
|
||||||
|
- `OPERATOR`: PPR management access
|
||||||
|
- `READ_ONLY`: View-only access
|
||||||
|
|
||||||
|
### Get User Details
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/auth/users/{user_id}`
|
||||||
|
|
||||||
|
### Update User
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/v1/auth/users/{user_id}`
|
||||||
|
|
||||||
|
### Delete User
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/v1/auth/users/{user_id}`
|
||||||
|
|
||||||
|
## Reference Data Endpoints
|
||||||
|
|
||||||
|
### Lookup Airport
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/airport/lookup/{icao_code}`
|
||||||
|
|
||||||
|
Returns airport details for the given ICAO code.
|
||||||
|
|
||||||
|
### Lookup Aircraft
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/aircraft/lookup/{registration}`
|
||||||
|
|
||||||
|
Returns aircraft details for the given registration.
|
||||||
|
|
||||||
|
## Reference Data Endpoints
|
||||||
|
|
||||||
## Public Endpoints (No Authentication Required)
|
## Public Endpoints (No Authentication Required)
|
||||||
|
|
||||||
### Get Today's Arrivals
|
### Get Today's Arrivals
|
||||||
@@ -159,6 +221,40 @@ Returns all PPRs with status "NEW" and ETA for today.
|
|||||||
|
|
||||||
Returns all PPRs with status "LANDED" and ETD for today.
|
Returns all PPRs with status "LANDED" and ETD for today.
|
||||||
|
|
||||||
|
### Submit Public PPR
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/public/pprs/`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
|
||||||
|
**Request Body:** Same as authenticated PPR creation (see above)
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- No authentication required
|
||||||
|
- Sends confirmation email if email provided
|
||||||
|
- Returns PPR with public edit token
|
||||||
|
|
||||||
|
### Get PPR for Public Editing
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/public/edit/{token}`
|
||||||
|
|
||||||
|
Returns PPR details for public editing using the token from email.
|
||||||
|
|
||||||
|
### Update PPR Publicly
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/v1/public/edit/{token}`
|
||||||
|
|
||||||
|
**Request Body:** Same as authenticated PPR update
|
||||||
|
|
||||||
|
**Notes:** Only allowed if PPR status is not processed (not LANDED, DEPARTED, CANCELED, or DELETED)
|
||||||
|
|
||||||
|
### Cancel PPR Publicly
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/v1/public/cancel/{token}`
|
||||||
|
|
||||||
|
Cancels PPR using public token and sends cancellation email.
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
### PPR Status Enum
|
### PPR Status Enum
|
||||||
@@ -256,10 +352,14 @@ The system automatically logs:
|
|||||||
|
|
||||||
## Web Interfaces
|
## Web Interfaces
|
||||||
|
|
||||||
### Public Arrivals/Departures Board
|
### Public PPR Forms
|
||||||
- **URL:** http://localhost:8082
|
- **URL:** http://localhost:8082
|
||||||
- **Features:** Real-time arrivals and departures display
|
- **Features:**
|
||||||
- **Authentication:** None required
|
- PPR submission with intelligent aircraft/airport field lookups
|
||||||
|
- Date/time pickers with 15-minute intervals
|
||||||
|
- Email notifications for submissions
|
||||||
|
- Public editing/cancellation via secure email tokens
|
||||||
|
- Real-time validation and feedback
|
||||||
|
|
||||||
### Admin Interface
|
### Admin Interface
|
||||||
- **URL:** http://localhost:8082/admin.html
|
- **URL:** http://localhost:8082/admin.html
|
||||||
@@ -270,15 +370,80 @@ The system automatically logs:
|
|||||||
- Journal/audit trail viewing
|
- Journal/audit trail viewing
|
||||||
- Quick status updates (Confirm, Land, Depart, Cancel)
|
- Quick status updates (Confirm, Land, Depart, Cancel)
|
||||||
- New PPR entry creation
|
- New PPR entry creation
|
||||||
|
- User management (administrators only)
|
||||||
|
- Real-time WebSocket updates
|
||||||
- **Authentication:** Username/password prompt (uses API token)
|
- **Authentication:** Username/password prompt (uses API token)
|
||||||
|
|
||||||
|
### Reports Interface
|
||||||
|
- **URL:** http://localhost:8082/reports.html
|
||||||
|
- **Features:**
|
||||||
|
- Comprehensive PPR reporting with date range filtering (defaults to current month)
|
||||||
|
- Search across aircraft registration, callsign, captain name, and airports
|
||||||
|
- Status-based filtering
|
||||||
|
- CSV and XLS export functionality
|
||||||
|
- Responsive table displaying all PPR fields
|
||||||
|
- Real-time data loading with authentication
|
||||||
|
|
||||||
## Development URLs
|
## Development URLs
|
||||||
|
|
||||||
- API Base: http://localhost:8001/api/v1
|
- API Base: http://localhost:8001/api/v1
|
||||||
- Public Web Interface: http://localhost:8082
|
- Public Web Interface: http://localhost:8082
|
||||||
- Admin Interface: http://localhost:8082/admin.html
|
- Admin Interface: http://localhost:8082/admin.html
|
||||||
|
- Reports Interface: http://localhost:8082/reports.html
|
||||||
- API Documentation: http://localhost:8001/docs
|
- API Documentation: http://localhost:8001/docs
|
||||||
- Database: localhost:3307 (MySQL)
|
- Database: localhost:3307 (MySQL)
|
||||||
|
- phpMyAdmin: http://localhost:8083
|
||||||
|
|
||||||
|
## Email System
|
||||||
|
|
||||||
|
The system includes transactional email support:
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Email settings are configured via environment variables:
|
||||||
|
- `SMTP_SERVER`: SMTP server hostname
|
||||||
|
- `SMTP_PORT`: SMTP server port (default: 587)
|
||||||
|
- `SMTP_USERNAME`: SMTP authentication username
|
||||||
|
- `SMTP_PASSWORD`: SMTP authentication password
|
||||||
|
- `FROM_EMAIL`: Sender email address
|
||||||
|
- `BASE_URL`: Base URL for email links
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
- **PPR Submitted**: Sent when public PPR is created
|
||||||
|
- **PPR Cancelled**: Sent when PPR is cancelled (by user or admin)
|
||||||
|
|
||||||
|
### Email Content
|
||||||
|
Emails include:
|
||||||
|
- PPR details (aircraft, times, contact info)
|
||||||
|
- Secure edit/cancel links with tokens
|
||||||
|
- Formatted HTML with system branding
|
||||||
|
|
||||||
|
## Test Data Generation
|
||||||
|
|
||||||
|
The system includes comprehensive test data generation utilities:
|
||||||
|
|
||||||
|
### Generate Test PPRs
|
||||||
|
```bash
|
||||||
|
# Run from host
|
||||||
|
./populate_test_data.sh
|
||||||
|
|
||||||
|
# Or run directly in container
|
||||||
|
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### What It Creates
|
||||||
|
- **93 PPR records** across all statuses (NEW, CONFIRMED, LANDED, DEPARTED, CANCELED)
|
||||||
|
- **Diverse aircraft types** from the loaded aircraft database
|
||||||
|
- **Realistic airport combinations** from the loaded airport data
|
||||||
|
- **Varied captains and contact details**
|
||||||
|
- **Proper date/time distributions** across different time periods
|
||||||
|
- **Status distribution** matching real-world usage patterns
|
||||||
|
|
||||||
|
### Test Data Features
|
||||||
|
- Aircraft registrations with proper formatting
|
||||||
|
- Realistic passenger counts and fuel requirements
|
||||||
|
- Geographic distribution across UK airports
|
||||||
|
- Time-based distribution for testing date filters
|
||||||
|
- Email addresses for testing notification system
|
||||||
|
|
||||||
## Example Workflow
|
## Example Workflow
|
||||||
|
|
||||||
@@ -309,4 +474,90 @@ curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '{"status":"LANDED"}'
|
-d '{"status":"LANDED"}'
|
||||||
|
|
||||||
|
# Test public PPR submission (no auth required)
|
||||||
|
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ac_reg":"G-PUBLIC","ac_type":"PA28","captain":"Public User","in_from":"EGGW","eta":"2025-10-21T15:00:00","pob_in":1,"email":"test@example.com"}'
|
||||||
|
|
||||||
|
# Test filtering by date range
|
||||||
|
curl -s "http://localhost:8001/api/v1/pprs/?date_from=2025-10-01&date_to=2025-10-31&limit=10" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
|
||||||
|
# Test user management (admin only)
|
||||||
|
curl -s "http://localhost:8001/api/v1/auth/users" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
|
||||||
|
# Generate test data
|
||||||
|
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Enhanced Features
|
||||||
|
|
||||||
|
### Intelligent Form Lookups
|
||||||
|
- **Aircraft Lookup**: Auto-suggests aircraft type based on registration
|
||||||
|
- **Airport Lookup**: Provides airport name and location for ICAO codes
|
||||||
|
- **Real-time Validation**: Immediate feedback on form inputs
|
||||||
|
|
||||||
|
### Advanced Date/Time Handling
|
||||||
|
- **15-Minute Intervals**: All time pickers use 15-minute increments
|
||||||
|
- **Separate Date/Time Fields**: Improved UX with dedicated date and time inputs
|
||||||
|
- **UTC Storage**: All times stored in UTC for consistency
|
||||||
|
|
||||||
|
### Comprehensive Reporting
|
||||||
|
- **Date Range Filtering**: Filter PPRs by custom date ranges
|
||||||
|
- **Multi-field Search**: Search across registration, callsign, captain, airports
|
||||||
|
- **Export Functionality**: CSV and XLS download with all PPR data
|
||||||
|
- **Responsive Design**: Works on desktop and mobile devices
|
||||||
|
|
||||||
|
### Audit Trail System
|
||||||
|
- **Complete Change History**: Every field change is logged
|
||||||
|
- **User Tracking**: Who made changes and when
|
||||||
|
- **IP Address Logging**: Security tracking for all actions
|
||||||
|
- **Automatic Journal Entries**: No manual logging required
|
||||||
|
|
||||||
|
## Example Complete Workflow
|
||||||
|
|
||||||
|
1. **Public Submission**
|
||||||
|
```bash
|
||||||
|
# User submits PPR via public form
|
||||||
|
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ac_reg":"G-WORKFLOW","ac_type":"C152","captain":"Workflow Test","in_from":"EGKB","eta":"2025-10-21T14:00:00","pob_in":1,"email":"workflow@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Email Notification Sent**
|
||||||
|
- User receives confirmation email with edit/cancel links
|
||||||
|
|
||||||
|
3. **Admin Processing**
|
||||||
|
```bash
|
||||||
|
# Admin confirms PPR
|
||||||
|
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"status":"CONFIRMED"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Status Updates**
|
||||||
|
```bash
|
||||||
|
# Mark as landed
|
||||||
|
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"status":"LANDED","timestamp":"2025-10-21T14:05:00Z"}'
|
||||||
|
|
||||||
|
# Mark as departed
|
||||||
|
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"status":"DEPARTED","timestamp":"2025-10-21T15:30:00Z"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Public Editing/Cancellation**
|
||||||
|
- User can edit or cancel using token from email
|
||||||
|
- System sends cancellation email if cancelled
|
||||||
|
|
||||||
|
6. **Reporting**
|
||||||
|
- Access reports at http://localhost:8082/reports.html
|
||||||
|
- Filter by date range, status, search terms
|
||||||
|
- Export to CSV/XLS for analysis
|
||||||
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Database Configuration
|
||||||
|
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here
|
||||||
|
DB_USER=your_database_user_here
|
||||||
|
DB_PASSWORD=your_database_password_here
|
||||||
|
DB_NAME=your_database_name_here
|
||||||
|
DB_PORT=3306
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
DB_HOST=db
|
||||||
|
SECRET_KEY=your_secret_key_here_change_in_production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
API_V1_STR=/api/v1
|
||||||
|
PROJECT_NAME=Airfield PPR API NextGen
|
||||||
|
API_PORT_EXTERNAL=8001
|
||||||
|
|
||||||
|
# Mail Configuration
|
||||||
|
MAIL_HOST=your_mail_host_here
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USERNAME=your_mail_username_here
|
||||||
|
MAIL_PASSWORD=your_mail_password_here
|
||||||
|
MAIL_FROM=your_mail_from_address_here
|
||||||
|
MAIL_FROM_NAME=your_mail_from_name_here
|
||||||
|
|
||||||
|
# Application settings
|
||||||
|
BASE_URL=your_base_url_here
|
||||||
|
|
||||||
|
# UI Configuration
|
||||||
|
TAG=
|
||||||
|
TOP_BAR_BASE_COLOR=#2c3e50
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Redis (optional)
|
||||||
|
REDIS_URL=
|
||||||
|
|
||||||
|
# Web Configuration
|
||||||
|
WEB_PORT_EXTERNAL=8082
|
||||||
|
|
||||||
|
# phpMyAdmin Configuration
|
||||||
|
PMA_HOST=db
|
||||||
|
UPLOAD_LIMIT=50M
|
||||||
|
PMA_PORT_EXTERNAL=8083
|
||||||
7
.gitignore
vendored
@@ -30,6 +30,10 @@ ENV/
|
|||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
|
# Alembic - keep migration files but not temporary files
|
||||||
|
# backend/alembic/versions/*.pyc
|
||||||
|
# backend/alembic/__pycache__/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -68,6 +72,9 @@ logs/
|
|||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Generated config files
|
||||||
|
web/config.js
|
||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
167
README.md
@@ -12,32 +12,88 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
|
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
|
||||||
- 🔐 **Authentication**: JWT-based authentication system
|
- 🔐 **Authentication**: JWT-based authentication system with role-based access
|
||||||
- 📊 **Real-time Updates**: WebSocket support for live tower updates
|
- <EFBFBD> **Transactional Email**: SMTP-based email notifications for PPR submissions and cancellations
|
||||||
- 🗄️ **Self-contained**: Fully dockerized with local database
|
- <EFBFBD>📊 **Real-time Updates**: WebSocket support for live tower updates
|
||||||
|
- <20> **Comprehensive Reporting**: Advanced reporting with filtering, search, and CSV/XLS export
|
||||||
|
- <20>🗄️ **Self-contained**: Fully dockerized with local database
|
||||||
- 🔍 **Documentation**: Auto-generated API docs at `/docs`
|
- 🔍 **Documentation**: Auto-generated API docs at `/docs`
|
||||||
- 🧪 **Testing**: Comprehensive test suite
|
- 🧪 **Testing**: Comprehensive test suite with data population utilities
|
||||||
- 📱 **Mobile Ready**: Responsive design for tower operations
|
- 📱 **Mobile Ready**: Responsive design for tower operations
|
||||||
|
- 🔄 **Public Forms**: Enhanced public PPR submission and editing with intelligent field lookups
|
||||||
|
- 📋 **Audit Trail**: Complete journal system tracking all PPR changes
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Docker and Docker Compose installed
|
- Docker and Docker Compose installed
|
||||||
|
|
||||||
### 1. Start the System
|
### Development Setup
|
||||||
|
|
||||||
|
**Start the system (automatic database setup):**
|
||||||
```bash
|
```bash
|
||||||
cd nextgen
|
docker compose up -d
|
||||||
./start.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That's it! The container automatically:
|
||||||
|
- Waits for database to be ready
|
||||||
|
- Creates schema via Alembic migrations
|
||||||
|
- Loads airport and aircraft reference data
|
||||||
|
- Starts the API with auto-reload
|
||||||
|
|
||||||
|
**View startup logs:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
**Simple automated deployment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Set your external database credentials
|
||||||
|
|
||||||
|
# 2. Start with production settings
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The container automatically handles:
|
||||||
|
- Database connection verification
|
||||||
|
- Schema creation/migration (Alembic)
|
||||||
|
- Reference data seeding (if needed)
|
||||||
|
- Production server startup (4 workers)
|
||||||
|
|
||||||
|
**Monitor deployment:**
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploying updates with migrations:**
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
# Migrations apply automatically!
|
||||||
|
```
|
||||||
|
|
||||||
|
**See detailed guides:**
|
||||||
|
- [`AUTOMATED_MIGRATION_GUIDE.md`](./AUTOMATED_MIGRATION_GUIDE.md) - How automatic migrations work
|
||||||
|
- [`PRODUCTION_MIGRATION_GUIDE.md`](./PRODUCTION_MIGRATION_GUIDE.md) - Advanced migration strategies
|
||||||
|
- [`MIGRATION_QUICK_REF.md`](./MIGRATION_QUICK_REF.md) - Quick reference commands
|
||||||
|
|
||||||
### 2. Access the Services
|
### 2. Access the Services
|
||||||
- **API Documentation**: http://localhost:8001/docs
|
- **API Documentation**: http://localhost:8001/docs
|
||||||
- **API Base URL**: http://localhost:8001/api/v1
|
- **API Base URL**: http://localhost:8001/api/v1
|
||||||
- **Database**: localhost:3307 (user: ppr_user, password: ppr_password123)
|
- **Public Web Interface**: http://localhost:8082
|
||||||
|
- **Admin Interface**: http://localhost:8082/admin.html
|
||||||
|
- **Reports Interface**: http://localhost:8082/reports.html
|
||||||
|
- **Database**: localhost:3307 (user: ppr_user, password: [configured in .env])
|
||||||
|
- **phpMyAdmin**: http://localhost:8083
|
||||||
|
|
||||||
### 3. Default Login
|
### 3. Default Login
|
||||||
- **Username**: admin
|
- **Username**: admin
|
||||||
- **Password**: admin123
|
- **Password**: [configured in database - see init_db.sql]
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -45,20 +101,68 @@ cd nextgen
|
|||||||
- `POST /api/v1/auth/login` - Login and get JWT token
|
- `POST /api/v1/auth/login` - Login and get JWT token
|
||||||
|
|
||||||
### PPR Management
|
### PPR Management
|
||||||
- `GET /api/v1/pprs` - List PPR records
|
- `GET /api/v1/pprs` - List PPR records (with filtering by status, date range)
|
||||||
- `POST /api/v1/pprs` - Create new PPR
|
- `POST /api/v1/pprs` - Create new PPR
|
||||||
- `GET /api/v1/pprs/{id}` - Get specific PPR
|
- `GET /api/v1/pprs/{id}` - Get specific PPR
|
||||||
- `PUT /api/v1/pprs/{id}` - Update PPR
|
- `PUT /api/v1/pprs/{id}` - Update PPR
|
||||||
|
- `PATCH /api/v1/pprs/{id}` - Partially update PPR
|
||||||
- `PATCH /api/v1/pprs/{id}/status` - Update PPR status
|
- `PATCH /api/v1/pprs/{id}/status` - Update PPR status
|
||||||
- `DELETE /api/v1/pprs/{id}` - Delete PPR
|
- `DELETE /api/v1/pprs/{id}` - Delete PPR
|
||||||
|
- `GET /api/v1/pprs/{id}/journal` - Get PPR activity journal
|
||||||
|
|
||||||
### Public Endpoints (No Auth Required)
|
### Public Endpoints (No Auth Required)
|
||||||
- `GET /api/v1/public/arrivals` - Today's arrivals
|
- `GET /api/v1/public/arrivals` - Today's arrivals
|
||||||
- `GET /api/v1/public/departures` - Today's departures
|
- `GET /api/v1/public/departures` - Today's departures
|
||||||
|
- `POST /api/v1/public/pprs` - Submit public PPR
|
||||||
|
- `GET /api/v1/public/edit/{token}` - Get PPR for public editing
|
||||||
|
- `PATCH /api/v1/public/edit/{token}` - Update PPR publicly
|
||||||
|
- `DELETE /api/v1/public/cancel/{token}` - Cancel PPR publicly
|
||||||
|
|
||||||
|
### User Management (Admin Only)
|
||||||
|
- `GET /api/v1/auth/users` - List users
|
||||||
|
- `POST /api/v1/auth/users` - Create user
|
||||||
|
- `GET /api/v1/auth/users/{id}` - Get user details
|
||||||
|
- `PUT /api/v1/auth/users/{id}` - Update user
|
||||||
|
- `DELETE /api/v1/auth/users/{id}` - Delete user
|
||||||
|
|
||||||
|
### Reference Data
|
||||||
|
- `GET /api/v1/airport/lookup/{code}` - Lookup airport by ICAO code
|
||||||
|
- `GET /api/v1/aircraft/lookup/{reg}` - Lookup aircraft by registration
|
||||||
|
|
||||||
### Real-time
|
### Real-time
|
||||||
- `WebSocket /ws/tower-updates` - Live updates for tower operations
|
- `WebSocket /ws/tower-updates` - Live updates for tower operations
|
||||||
|
|
||||||
|
## Web Interfaces
|
||||||
|
|
||||||
|
### Public PPR Forms
|
||||||
|
- **URL**: http://localhost:8082
|
||||||
|
- **Features**:
|
||||||
|
- PPR submission form with intelligent aircraft/airport lookups
|
||||||
|
- Date/time pickers with 15-minute intervals
|
||||||
|
- Email notifications for submissions
|
||||||
|
- Public editing/cancellation via secure tokens
|
||||||
|
|
||||||
|
### Admin Interface
|
||||||
|
- **URL**: http://localhost:8082/admin.html
|
||||||
|
- **Features**:
|
||||||
|
- Complete PPR management (CRUD operations)
|
||||||
|
- Advanced filtering by status, date range
|
||||||
|
- Inline editing with modal interface
|
||||||
|
- Journal/audit trail viewing
|
||||||
|
- Quick status updates (Confirm, Land, Depart, Cancel)
|
||||||
|
- New PPR entry creation
|
||||||
|
- User management (administrators only)
|
||||||
|
- Real-time WebSocket updates
|
||||||
|
|
||||||
|
### Reports Interface
|
||||||
|
- **URL**: http://localhost:8082/reports.html
|
||||||
|
- **Features**:
|
||||||
|
- Comprehensive PPR reporting with date range filtering
|
||||||
|
- Search across aircraft, captain, and airport fields
|
||||||
|
- Status-based filtering
|
||||||
|
- CSV and XLS export functionality
|
||||||
|
- Responsive table with all PPR details
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -73,12 +177,7 @@ uvicorn app.main:app --reload
|
|||||||
# Connect to database
|
# Connect to database
|
||||||
docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
||||||
|
|
||||||
# View logs
|
# When prompted for password, use the value from .env (DB_PASSWORD)
|
||||||
docker-compose logs -f api
|
|
||||||
docker-compose logs -f db
|
|
||||||
|
|
||||||
# Restart services
|
|
||||||
docker-compose restart
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -87,6 +186,33 @@ cd backend
|
|||||||
pytest tests/
|
pytest tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Additional Features
|
||||||
|
|
||||||
|
### Email Notifications
|
||||||
|
The system includes transactional email support for:
|
||||||
|
- **PPR Submissions**: Automatic email confirmation to submitters
|
||||||
|
- **PPR Cancellations**: Notification emails when PPRs are cancelled
|
||||||
|
- **SMTP Configuration**: Configurable SMTP settings via environment variables
|
||||||
|
|
||||||
|
### Test Data Generation
|
||||||
|
A comprehensive test data population script is included:
|
||||||
|
```bash
|
||||||
|
# Generate test PPR records
|
||||||
|
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||||
|
|
||||||
|
# Or run the convenience script
|
||||||
|
./populate_test_data.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates diverse PPR records across all statuses with realistic aircraft and airport data for testing purposes.
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
Complete activity logging system tracks:
|
||||||
|
- All PPR field changes with before/after values
|
||||||
|
- Status transitions with timestamps
|
||||||
|
- User actions and IP addresses
|
||||||
|
- Automatic journal entries for all modifications
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Key environment variables (configured in docker-compose.yml):
|
Key environment variables (configured in docker-compose.yml):
|
||||||
@@ -98,6 +224,15 @@ Key environment variables (configured in docker-compose.yml):
|
|||||||
- `SECRET_KEY` - JWT secret key
|
- `SECRET_KEY` - JWT secret key
|
||||||
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time
|
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time
|
||||||
|
|
||||||
|
### Email Configuration
|
||||||
|
- `SMTP_SERVER` - SMTP server hostname
|
||||||
|
- `SMTP_PORT` - SMTP server port (default: 587)
|
||||||
|
- `SMTP_USERNAME` - SMTP authentication username
|
||||||
|
- `SMTP_PASSWORD` - SMTP authentication password
|
||||||
|
- `SMTP_TLS` - Enable TLS (default: true)
|
||||||
|
- `FROM_EMAIL` - Sender email address
|
||||||
|
- `BASE_URL` - Base URL for email links
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
The system uses an improved version of the original schema with:
|
The system uses an improved version of the original schema with:
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
RUN chmod +x /app/entrypoint.sh && \
|
||||||
|
chmod +x /app/seed_data.py
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run the application
|
# Use entrypoint script
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
|
||||||
114
backend/alembic.ini
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Database URL - will be overridden by env.py from environment variables
|
||||||
|
sqlalchemy.url = mysql+pymysql://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
92
backend/alembic/env.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add the app directory to the path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import Base
|
||||||
|
# Import all models to ensure they are registered with Base
|
||||||
|
from app.models.ppr import PPRRecord, User, Airport, Aircraft
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Override sqlalchemy.url with the one from settings
|
||||||
|
config.set_main_option('sqlalchemy.url', settings.database_url)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
165
backend/alembic/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Initial schema baseline
|
||||||
|
|
||||||
|
Revision ID: 001_initial_schema
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-04 12:00:00.000000
|
||||||
|
|
||||||
|
This is the baseline migration that captures the current database schema.
|
||||||
|
For existing databases, this migration should be marked as applied without running it.
|
||||||
|
For new databases, this creates the complete initial schema.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '001_initial_schema'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
Create all tables for a fresh database installation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Users table
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('role', sa.Enum('ADMINISTRATOR', 'OPERATOR', 'READ_ONLY', name='userrole'), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('full_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Integer(), nullable=False, server_default='1'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
op.create_index('idx_username', 'users', ['username'])
|
||||||
|
op.create_index('idx_email', 'users', ['email'])
|
||||||
|
|
||||||
|
# Main PPR submissions table
|
||||||
|
op.create_table('submitted',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('NEW', 'CONFIRMED', 'CANCELED', 'LANDED', 'DELETED', 'DEPARTED', name='pprstatus'), nullable=False, server_default='NEW'),
|
||||||
|
sa.Column('ac_reg', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('ac_type', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('ac_call', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('captain', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('fuel', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('in_from', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('eta', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('pob_in', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('out_to', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('etd', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('pob_out', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('email', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('landed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('departed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('submitted_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('public_token', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_token'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
op.create_index('idx_status', 'submitted', ['status'])
|
||||||
|
op.create_index('idx_eta', 'submitted', ['eta'])
|
||||||
|
op.create_index('idx_etd', 'submitted', ['etd'])
|
||||||
|
op.create_index('idx_ac_reg', 'submitted', ['ac_reg'])
|
||||||
|
op.create_index('idx_submitted_dt', 'submitted', ['submitted_dt'])
|
||||||
|
op.create_index('idx_created_by', 'submitted', ['created_by'])
|
||||||
|
op.create_index('idx_public_token', 'submitted', ['public_token'])
|
||||||
|
|
||||||
|
# Activity journal table
|
||||||
|
op.create_table('journal',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('ppr_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('entry', sa.Text(), nullable=False),
|
||||||
|
sa.Column('user', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('ip', sa.String(length=45), nullable=False),
|
||||||
|
sa.Column('entry_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['ppr_id'], ['submitted.id'], ondelete='CASCADE'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
op.create_index('idx_ppr_id', 'journal', ['ppr_id'])
|
||||||
|
op.create_index('idx_entry_dt', 'journal', ['entry_dt'])
|
||||||
|
op.create_index('idx_user', 'journal', ['user'])
|
||||||
|
|
||||||
|
# Airports reference table
|
||||||
|
op.create_table('airports',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('icao', sa.String(length=4), nullable=False),
|
||||||
|
sa.Column('iata', sa.String(length=3), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('country', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('city', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('timezone', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('latitude', mysql.DECIMAL(precision=10, scale=8), nullable=True),
|
||||||
|
sa.Column('longitude', mysql.DECIMAL(precision=11, scale=8), nullable=True),
|
||||||
|
sa.Column('elevation', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('icao', name='unique_icao'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
op.create_index('idx_iata', 'airports', ['iata'])
|
||||||
|
op.create_index('idx_country', 'airports', ['country'])
|
||||||
|
op.create_index('idx_name', 'airports', ['name'])
|
||||||
|
|
||||||
|
# Aircraft reference table
|
||||||
|
op.create_table('aircraft',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('icao24', sa.String(length=6), nullable=True),
|
||||||
|
sa.Column('registration', sa.String(length=25), nullable=True),
|
||||||
|
sa.Column('manufacturer_icao', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('type_code', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('manufacturer_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('model', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('clean_reg', sa.String(length=25), sa.Computed("UPPER(REPLACE(REPLACE(registration, '-', ''), ' ', ''))", persisted=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
op.create_index('idx_registration', 'aircraft', ['registration'])
|
||||||
|
op.create_index('idx_clean_reg', 'aircraft', ['clean_reg'])
|
||||||
|
op.create_index('idx_icao24', 'aircraft', ['icao24'])
|
||||||
|
op.create_index('idx_type_code', 'aircraft', ['type_code'])
|
||||||
|
|
||||||
|
# Insert default admin user (password: admin123)
|
||||||
|
# This should be changed immediately in production
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO users (username, password, role, email, full_name) VALUES
|
||||||
|
('admin', '$2b$12$BJOha2yRxkxuHL./BaMfpu2fMDgGMYISuRV2.B1sSklVpRjz3Y4a6', 'ADMINISTRATOR', 'admin@ppr.local', 'System Administrator')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
Drop all tables - USE WITH CAUTION IN PRODUCTION
|
||||||
|
"""
|
||||||
|
op.drop_table('journal')
|
||||||
|
op.drop_table('submitted')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_table('aircraft')
|
||||||
|
op.drop_table('airports')
|
||||||
216
backend/alembic/versions/002_local_flights.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Add local_flights table for tracking local flights
|
||||||
|
|
||||||
|
Revision ID: 002_local_flights
|
||||||
|
Revises: 001_initial_schema
|
||||||
|
Create Date: 2025-12-12 12:00:00.000000
|
||||||
|
|
||||||
|
This migration adds a new table for tracking local flights (circuits, local, departure)
|
||||||
|
that don't require PPR submissions. Also adds etd and renames booked_out_dt to created_dt,
|
||||||
|
and departure_dt to departed_dt for consistency. Transforms journal table from PPR-specific
|
||||||
|
to a generic polymorphic journal for all entity types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '002_local_flights'
|
||||||
|
down_revision = '001_initial_schema'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
Create local_flights, departures, and arrivals tables.
|
||||||
|
Transform journal table from PPR-specific to generic polymorphic journal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Modify existing journal table to support all entity types
|
||||||
|
# First add new columns (check if they don't already exist)
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# Get table columns to check if entity_type and entity_id already exist
|
||||||
|
connection = op.get_context().bind
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('journal')]
|
||||||
|
|
||||||
|
if 'entity_type' not in columns:
|
||||||
|
op.add_column('journal', sa.Column('entity_type', sa.String(50), nullable=True))
|
||||||
|
if 'entity_id' not in columns:
|
||||||
|
op.add_column('journal', sa.Column('entity_id', sa.BigInteger(), nullable=True))
|
||||||
|
|
||||||
|
# Migrate existing PPR journal entries: backfill entity_type and entity_id
|
||||||
|
op.execute("""
|
||||||
|
UPDATE journal SET
|
||||||
|
entity_type = 'PPR',
|
||||||
|
entity_id = ppr_id
|
||||||
|
WHERE entity_type IS NULL AND ppr_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make new columns NOT NULL after migration
|
||||||
|
op.alter_column('journal', 'entity_type', existing_type=sa.String(50), nullable=False)
|
||||||
|
op.alter_column('journal', 'entity_id', existing_type=sa.BigInteger(), nullable=False)
|
||||||
|
|
||||||
|
# Make ip column nullable (new entries won't always have it)
|
||||||
|
op.alter_column('journal', 'ip', existing_type=sa.String(45), nullable=True)
|
||||||
|
|
||||||
|
# Drop the foreign key constraint before dropping the column
|
||||||
|
if 'ppr_id' in columns:
|
||||||
|
op.drop_constraint('journal_ibfk_1', 'journal', type_='foreignkey')
|
||||||
|
op.drop_column('journal', 'ppr_id')
|
||||||
|
|
||||||
|
# Add composite index for efficient queries
|
||||||
|
op.create_index('idx_entity_lookup', 'journal', ['entity_type', 'entity_id'])
|
||||||
|
|
||||||
|
# Drop old index if it exists
|
||||||
|
try:
|
||||||
|
op.drop_index('idx_ppr_id', table_name='journal')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
op.create_table('local_flights',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('registration', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'),
|
||||||
|
sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'),
|
||||||
|
sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('etd', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('departed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('landed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for local_flights
|
||||||
|
op.create_index('idx_registration', 'local_flights', ['registration'])
|
||||||
|
op.create_index('idx_flight_type', 'local_flights', ['flight_type'])
|
||||||
|
op.create_index('idx_status', 'local_flights', ['status'])
|
||||||
|
op.create_index('idx_created_dt', 'local_flights', ['created_dt'])
|
||||||
|
op.create_index('idx_etd', 'local_flights', ['etd'])
|
||||||
|
op.create_index('idx_created_by', 'local_flights', ['created_by'])
|
||||||
|
|
||||||
|
# Create departures table for non-PPR departures to other airports
|
||||||
|
op.create_table('departures',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('registration', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('out_to', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'CANCELLED', name='departuresstatus'), nullable=False, server_default='BOOKED_OUT'),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('etd', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('departed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index('idx_dep_registration', 'departures', ['registration'])
|
||||||
|
op.create_index('idx_dep_out_to', 'departures', ['out_to'])
|
||||||
|
op.create_index('idx_dep_status', 'departures', ['status'])
|
||||||
|
op.create_index('idx_dep_created_dt', 'departures', ['created_dt'])
|
||||||
|
op.create_index('idx_dep_etd', 'departures', ['etd'])
|
||||||
|
op.create_index('idx_dep_created_by', 'departures', ['created_by'])
|
||||||
|
|
||||||
|
# Create arrivals table for non-PPR arrivals from elsewhere
|
||||||
|
op.create_table('arrivals',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('registration', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('in_from', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('BOOKED_IN', 'LANDED', 'CANCELLED', name='arrivalsstatus'), nullable=False, server_default='BOOKED_IN'),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('eta', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('landed_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index('idx_arr_registration', 'arrivals', ['registration'])
|
||||||
|
op.create_index('idx_arr_in_from', 'arrivals', ['in_from'])
|
||||||
|
op.create_index('idx_arr_status', 'arrivals', ['status'])
|
||||||
|
op.create_index('idx_arr_created_dt', 'arrivals', ['created_dt'])
|
||||||
|
op.create_index('idx_arr_eta', 'arrivals', ['eta'])
|
||||||
|
op.create_index('idx_arr_created_by', 'arrivals', ['created_by'])
|
||||||
|
|
||||||
|
# Create circuits table for tracking touch-and-go events during circuit training
|
||||||
|
op.create_table('circuits',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('local_flight_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('circuit_timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['local_flight_id'], ['local_flights.id'], ondelete='CASCADE'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for circuits
|
||||||
|
op.create_index('idx_circuit_local_flight_id', 'circuits', ['local_flight_id'])
|
||||||
|
op.create_index('idx_circuit_timestamp', 'circuits', ['circuit_timestamp'])
|
||||||
|
|
||||||
|
# Create overflights table for tracking aircraft talking to the tower but not departing/landing
|
||||||
|
op.create_table('overflights',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('registration', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('type', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('departure_airfield', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('destination_airfield', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'CANCELLED', name='overflightstatus'), nullable=False, server_default='ACTIVE'),
|
||||||
|
sa.Column('call_dt', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('qsy_dt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for overflights
|
||||||
|
op.create_index('idx_ovf_registration', 'overflights', ['registration'])
|
||||||
|
op.create_index('idx_ovf_departure_airfield', 'overflights', ['departure_airfield'])
|
||||||
|
op.create_index('idx_ovf_destination_airfield', 'overflights', ['destination_airfield'])
|
||||||
|
op.create_index('idx_ovf_status', 'overflights', ['status'])
|
||||||
|
op.create_index('idx_ovf_call_dt', 'overflights', ['call_dt'])
|
||||||
|
op.create_index('idx_ovf_created_dt', 'overflights', ['created_dt'])
|
||||||
|
op.create_index('idx_ovf_created_by', 'overflights', ['created_by'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
Drop the overflights, circuits, arrivals, departures, and local_flights tables.
|
||||||
|
"""
|
||||||
|
op.drop_table('overflights')
|
||||||
|
op.drop_table('circuits')
|
||||||
|
op.drop_table('arrivals')
|
||||||
|
op.drop_table('departures')
|
||||||
|
op.drop_table('local_flights')
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import auth, pprs, public, aircraft, airport
|
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||||
api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"])
|
api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"])
|
||||||
|
api_router.include_router(local_flights.router, prefix="/local-flights", tags=["local_flights"])
|
||||||
|
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
|
||||||
|
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
|
||||||
|
api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"])
|
||||||
|
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
|
||||||
|
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
|
||||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -69,3 +69,38 @@ async def search_airports(
|
|||||||
).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
|
||||||
179
backend/app/api/endpoints/arrivals.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_arrival import arrival as crud_arrival
|
||||||
|
from app.schemas.arrival import Arrival, ArrivalCreate, ArrivalUpdate, ArrivalStatus, ArrivalStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Arrival])
|
||||||
|
async def get_arrivals(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[ArrivalStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get arrival records with optional filtering"""
|
||||||
|
arrivals = crud_arrival.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return arrivals
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Arrival)
|
||||||
|
async def create_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_in: ArrivalCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new arrival record"""
|
||||||
|
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_booked_in",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"in_from": arrival.in_from,
|
||||||
|
"status": arrival.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def get_arrival(
|
||||||
|
arrival_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific arrival record"""
|
||||||
|
arrival = crud_arrival.get(db, arrival_id=arrival_id)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def update_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
arrival_in: ArrivalUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update an arrival record"""
|
||||||
|
db_arrival = crud_arrival.get(db, arrival_id=arrival_id)
|
||||||
|
if not db_arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
arrival = crud_arrival.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_arrival,
|
||||||
|
obj_in=arrival_in,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_updated",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"status": arrival.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{arrival_id}/status", response_model=Arrival)
|
||||||
|
async def update_arrival_status(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
status_update: ArrivalStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update arrival status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
arrival = crud_arrival.update_status(
|
||||||
|
db,
|
||||||
|
arrival_id=arrival_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"status": arrival.status.value,
|
||||||
|
"landed_dt": arrival.landed_dt.isoformat() if arrival.landed_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def cancel_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel an arrival record"""
|
||||||
|
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_admin_user, get_current_read_user
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_access_token
|
from app.core.security import create_access_token
|
||||||
from app.crud.crud_user import user as crud_user
|
from app.crud.crud_user import user as crud_user
|
||||||
from app.schemas.ppr import Token, UserCreate, UserUpdate, User
|
from app.schemas.ppr import Token, UserCreate, UserUpdate, User, ChangePassword
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ async def login_for_access_token(
|
|||||||
subject=user.username, expires_delta=access_token_expires
|
subject=user.username, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": settings.access_token_expire_minutes * 60 # seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-token", response_model=User)
|
@router.post("/test-token", response_model=User)
|
||||||
@@ -54,6 +58,22 @@ async def list_users(
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}", response_model=User)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Get a specific user's details (admin only)"""
|
||||||
|
user = crud_user.get(db, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users", response_model=User)
|
@router.post("/users", response_model=User)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user_in: UserCreate,
|
user_in: UserCreate,
|
||||||
@@ -87,3 +107,21 @@ async def update_user(
|
|||||||
)
|
)
|
||||||
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/change-password", response_model=User)
|
||||||
|
async def change_user_password(
|
||||||
|
user_id: int,
|
||||||
|
password_data: ChangePassword,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Change a user's password (admin only)"""
|
||||||
|
user = crud_user.get(db, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
|
||||||
|
return user
|
||||||
108
backend/app/api/endpoints/circuits.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_circuit import crud_circuit
|
||||||
|
from app.schemas.circuit import Circuit, CircuitCreate, CircuitUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Circuit])
|
||||||
|
async def get_circuits(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get circuit records"""
|
||||||
|
circuits = crud_circuit.get_multi(db, skip=skip, limit=limit)
|
||||||
|
return circuits
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flight/{local_flight_id}", response_model=List[Circuit])
|
||||||
|
async def get_circuits_by_flight(
|
||||||
|
local_flight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get all circuits for a specific local flight"""
|
||||||
|
circuits = crud_circuit.get_by_local_flight(db, local_flight_id=local_flight_id)
|
||||||
|
return circuits
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Circuit)
|
||||||
|
async def create_circuit(
|
||||||
|
request: Request,
|
||||||
|
circuit_in: CircuitCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Record a new circuit (touch and go) for a local flight"""
|
||||||
|
circuit = crud_circuit.create(db, obj_in=circuit_in)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "circuit_recorded",
|
||||||
|
"data": {
|
||||||
|
"id": circuit.id,
|
||||||
|
"local_flight_id": circuit.local_flight_id,
|
||||||
|
"circuit_timestamp": circuit.circuit_timestamp.isoformat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{circuit_id}", response_model=Circuit)
|
||||||
|
async def get_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{circuit_id}", response_model=Circuit)
|
||||||
|
async def update_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
circuit_in: CircuitUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update a circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
circuit = crud_circuit.update(db, db_obj=circuit, obj_in=circuit_in)
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{circuit_id}")
|
||||||
|
async def delete_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Delete a circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
crud_circuit.delete(db, circuit_id=circuit_id)
|
||||||
|
return {"detail": "Circuit record deleted"}
|
||||||
179
backend/app/api/endpoints/departures.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_departure import departure as crud_departure
|
||||||
|
from app.schemas.departure import Departure, DepartureCreate, DepartureUpdate, DepartureStatus, DepartureStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Departure])
|
||||||
|
async def get_departures(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[DepartureStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get departure records with optional filtering"""
|
||||||
|
departures = crud_departure.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return departures
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Departure)
|
||||||
|
async def create_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_in: DepartureCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new departure record"""
|
||||||
|
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_booked_out",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"out_to": departure.out_to,
|
||||||
|
"status": departure.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{departure_id}", response_model=Departure)
|
||||||
|
async def get_departure(
|
||||||
|
departure_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific departure record"""
|
||||||
|
departure = crud_departure.get(db, departure_id=departure_id)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{departure_id}", response_model=Departure)
|
||||||
|
async def update_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
departure_in: DepartureUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update a departure record"""
|
||||||
|
db_departure = crud_departure.get(db, departure_id=departure_id)
|
||||||
|
if not db_departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
departure = crud_departure.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_departure,
|
||||||
|
obj_in=departure_in,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_updated",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"status": departure.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{departure_id}/status", response_model=Departure)
|
||||||
|
async def update_departure_status(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
status_update: DepartureStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update departure status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
departure = crud_departure.update_status(
|
||||||
|
db,
|
||||||
|
departure_id=departure_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"status": departure.status.value,
|
||||||
|
"departed_dt": departure.departed_dt.isoformat() if departure.departed_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{departure_id}", response_model=Departure)
|
||||||
|
async def cancel_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel a departure record"""
|
||||||
|
departure = crud_departure.cancel(db, departure_id=departure_id)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
63
backend/app/api/endpoints/journal.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.api import deps
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
router = APIRouter(tags=["journal"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
|
||||||
|
async def get_entity_journal(
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: int,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get journal entries for a specific entity (PPR, LOCAL_FLIGHT, ARRIVAL, or DEPARTURE).
|
||||||
|
|
||||||
|
The journal is immutable - entries are created automatically by the backend
|
||||||
|
when changes are made. This endpoint is read-only.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- entity_type: One of 'PPR', 'LOCAL_FLIGHT', 'ARRIVAL', 'DEPARTURE'
|
||||||
|
- entity_id: The ID of the entity
|
||||||
|
- limit: Maximum number of entries to return (default 100)
|
||||||
|
"""
|
||||||
|
# Validate entity type
|
||||||
|
try:
|
||||||
|
entity = EntityType[entity_type.upper()]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = journal.get_entity_journal(db, entity, entity_id, limit=limit)
|
||||||
|
|
||||||
|
return EntityJournalResponse(
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entries=entries,
|
||||||
|
total_entries=len(entries)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
|
||||||
|
async def get_user_journal(
|
||||||
|
username: str,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all journal entries created by a specific user.
|
||||||
|
|
||||||
|
This endpoint is read-only and returns entries in reverse chronological order.
|
||||||
|
"""
|
||||||
|
entries = journal.get_user_journal(db, username, limit=limit)
|
||||||
|
return entries
|
||||||
207
backend/app/api/endpoints/local_flights.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_local_flight import local_flight as crud_local_flight
|
||||||
|
from app.schemas.local_flight import LocalFlight, LocalFlightCreate, LocalFlightUpdate, LocalFlightStatus, LocalFlightType, LocalFlightStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[LocalFlight])
|
||||||
|
async def get_local_flights(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[LocalFlightStatus] = None,
|
||||||
|
flight_type: Optional[LocalFlightType] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get local flight records with optional filtering"""
|
||||||
|
flights = crud_local_flight.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
flight_type=flight_type, date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return flights
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=LocalFlight)
|
||||||
|
async def create_local_flight(
|
||||||
|
request: Request,
|
||||||
|
flight_in: LocalFlightCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new local flight record (book out)"""
|
||||||
|
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "local_flight_booked_out",
|
||||||
|
"data": {
|
||||||
|
"id": flight.id,
|
||||||
|
"registration": flight.registration,
|
||||||
|
"flight_type": flight.flight_type.value,
|
||||||
|
"status": flight.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return flight
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{flight_id}", response_model=LocalFlight)
|
||||||
|
async def get_local_flight(
|
||||||
|
flight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific local flight record"""
|
||||||
|
flight = crud_local_flight.get(db, flight_id=flight_id)
|
||||||
|
if not flight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Local flight record not found"
|
||||||
|
)
|
||||||
|
return flight
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{flight_id}", response_model=LocalFlight)
|
||||||
|
async def update_local_flight(
|
||||||
|
request: Request,
|
||||||
|
flight_id: int,
|
||||||
|
flight_in: LocalFlightUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update a local flight record"""
|
||||||
|
db_flight = crud_local_flight.get(db, flight_id=flight_id)
|
||||||
|
if not db_flight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Local flight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
flight = crud_local_flight.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_flight,
|
||||||
|
obj_in=flight_in,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "local_flight_updated",
|
||||||
|
"data": {
|
||||||
|
"id": flight.id,
|
||||||
|
"registration": flight.registration,
|
||||||
|
"status": flight.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return flight
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{flight_id}/status", response_model=LocalFlight)
|
||||||
|
async def update_local_flight_status(
|
||||||
|
request: Request,
|
||||||
|
flight_id: int,
|
||||||
|
status_update: LocalFlightStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update local flight status (LANDED, CANCELLED, etc.)"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
flight = crud_local_flight.update_status(
|
||||||
|
db,
|
||||||
|
flight_id=flight_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not flight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Local flight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "local_flight_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": flight.id,
|
||||||
|
"registration": flight.registration,
|
||||||
|
"status": flight.status.value,
|
||||||
|
"landed_dt": flight.landed_dt.isoformat() if flight.landed_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return flight
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{flight_id}", response_model=LocalFlight)
|
||||||
|
async def cancel_local_flight(
|
||||||
|
request: Request,
|
||||||
|
flight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel a local flight record"""
|
||||||
|
flight = crud_local_flight.cancel(db, flight_id=flight_id)
|
||||||
|
if not flight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Local flight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "local_flight_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": flight.id,
|
||||||
|
"registration": flight.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return flight
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active/current", response_model=List[LocalFlight])
|
||||||
|
async def get_active_flights(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get currently active (booked out) flights"""
|
||||||
|
return crud_local_flight.get_active_flights(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today/departures", response_model=List[LocalFlight])
|
||||||
|
async def get_today_departures(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get today's departures (booked out or departed)"""
|
||||||
|
return crud_local_flight.get_departures_today(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today/booked-out", response_model=List[LocalFlight])
|
||||||
|
async def get_today_booked_out(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get all flights booked out today"""
|
||||||
|
return crud_local_flight.get_booked_out_today(db)
|
||||||
206
backend/app/api/endpoints/overflights.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_overflight import overflight as crud_overflight
|
||||||
|
from app.schemas.overflight import Overflight, OverflightCreate, OverflightUpdate, OverflightStatus, OverflightStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Overflight])
|
||||||
|
async def get_overflights(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[OverflightStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get overflight records with optional filtering"""
|
||||||
|
overflights = crud_overflight.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return overflights
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Overflight)
|
||||||
|
async def create_overflight(
|
||||||
|
request: Request,
|
||||||
|
overflight_in: OverflightCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new overflight record"""
|
||||||
|
overflight = crud_overflight.create(db, obj_in=overflight_in, created_by=current_user.username)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "overflight_created",
|
||||||
|
"data": {
|
||||||
|
"id": overflight.id,
|
||||||
|
"registration": overflight.registration,
|
||||||
|
"departure_airfield": overflight.departure_airfield,
|
||||||
|
"destination_airfield": overflight.destination_airfield,
|
||||||
|
"status": overflight.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return overflight
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{overflight_id}", response_model=Overflight)
|
||||||
|
async def get_overflight(
|
||||||
|
overflight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific overflight record"""
|
||||||
|
overflight = crud_overflight.get(db, overflight_id=overflight_id)
|
||||||
|
if not overflight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Overflight record not found"
|
||||||
|
)
|
||||||
|
return overflight
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{overflight_id}", response_model=Overflight)
|
||||||
|
async def update_overflight(
|
||||||
|
request: Request,
|
||||||
|
overflight_id: int,
|
||||||
|
overflight_in: OverflightUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update an overflight record"""
|
||||||
|
db_overflight = crud_overflight.get(db, overflight_id=overflight_id)
|
||||||
|
if not db_overflight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Overflight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
overflight = crud_overflight.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_overflight,
|
||||||
|
obj_in=overflight_in,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "overflight_updated",
|
||||||
|
"data": {
|
||||||
|
"id": overflight.id,
|
||||||
|
"registration": overflight.registration,
|
||||||
|
"status": overflight.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return overflight
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{overflight_id}/status", response_model=Overflight)
|
||||||
|
async def update_overflight_status(
|
||||||
|
request: Request,
|
||||||
|
overflight_id: int,
|
||||||
|
status_update: OverflightStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update overflight status (ACTIVE -> INACTIVE for QSY)"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
overflight = crud_overflight.update_status(
|
||||||
|
db,
|
||||||
|
overflight_id=overflight_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.qsy_dt,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not overflight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Overflight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "overflight_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": overflight.id,
|
||||||
|
"registration": overflight.registration,
|
||||||
|
"status": overflight.status.value,
|
||||||
|
"qsy_dt": overflight.qsy_dt.isoformat() if overflight.qsy_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return overflight
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{overflight_id}", response_model=Overflight)
|
||||||
|
async def cancel_overflight(
|
||||||
|
request: Request,
|
||||||
|
overflight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel an overflight record"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
overflight = crud_overflight.cancel(
|
||||||
|
db,
|
||||||
|
overflight_id=overflight_id,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not overflight:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Overflight record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "overflight_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": overflight.id,
|
||||||
|
"registration": overflight.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return overflight
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active/list", response_model=List[Overflight])
|
||||||
|
async def get_active_overflights(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get currently active overflights"""
|
||||||
|
overflights = crud_overflight.get_active_overflights(db)
|
||||||
|
return overflights
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today/list", response_model=List[Overflight])
|
||||||
|
async def get_overflights_today(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get today's overflights"""
|
||||||
|
overflights = crud_overflight.get_overflights_today(db)
|
||||||
|
return overflights
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,20 +3,240 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.crud.crud_ppr import ppr as crud_ppr
|
from app.crud.crud_ppr import ppr as crud_ppr
|
||||||
from app.schemas.ppr import PPR
|
from app.crud.crud_local_flight import local_flight as crud_local_flight
|
||||||
|
from app.crud.crud_departure import departure as crud_departure
|
||||||
|
from app.crud.crud_arrival import arrival as crud_arrival
|
||||||
|
from app.schemas.ppr import PPRPublic
|
||||||
|
from app.models.local_flight import LocalFlightStatus
|
||||||
|
from app.models.departure import DepartureStatus
|
||||||
|
from app.models.arrival import ArrivalStatus
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import re
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/arrivals", response_model=List[PPR])
|
def lighten_color(hex_color, factor=0.3):
|
||||||
|
"""Lighten a hex color by a factor (0-1)"""
|
||||||
|
hex_color = hex_color.lstrip('#')
|
||||||
|
if len(hex_color) != 6:
|
||||||
|
return hex_color # Invalid, return as is
|
||||||
|
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||||
|
r = min(255, int(r + (255 - r) * factor))
|
||||||
|
g = min(255, int(g + (255 - g) * factor))
|
||||||
|
b = min(255, int(b + (255 - b) * factor))
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def darken_color(hex_color, factor=0.3):
|
||||||
|
"""Darken a hex color by a factor (0-1)"""
|
||||||
|
hex_color = hex_color.lstrip('#')
|
||||||
|
if len(hex_color) != 6:
|
||||||
|
return hex_color # Invalid, return as is
|
||||||
|
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||||
|
r = max(0, int(r * (1 - factor)))
|
||||||
|
g = max(0, int(g * (1 - factor)))
|
||||||
|
b = max(0, int(b * (1 - factor)))
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/arrivals")
|
||||||
async def get_public_arrivals(db: Session = Depends(get_db)):
|
async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||||
"""Get today's arrivals for public display"""
|
"""Get today's arrivals for public display (PPR and local flights)"""
|
||||||
arrivals = crud_ppr.get_arrivals_today(db)
|
arrivals = crud_ppr.get_arrivals_today(db)
|
||||||
return arrivals
|
|
||||||
|
# Convert PPR arrivals to dictionaries
|
||||||
|
arrivals_list = []
|
||||||
|
for arrival in arrivals:
|
||||||
|
arrivals_list.append({
|
||||||
|
'ac_call': arrival.ac_call,
|
||||||
|
'ac_reg': arrival.ac_reg,
|
||||||
|
'ac_type': arrival.ac_type,
|
||||||
|
'in_from': arrival.in_from,
|
||||||
|
'eta': arrival.eta,
|
||||||
|
'landed_dt': arrival.landed_dt,
|
||||||
|
'status': arrival.status.value,
|
||||||
|
'isLocalFlight': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add local flights with DEPARTED status that were booked out today
|
||||||
|
local_flights = crud_local_flight.get_multi(
|
||||||
|
db,
|
||||||
|
status=LocalFlightStatus.DEPARTED,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert local flights to match the PPR format for display
|
||||||
|
for flight in local_flights:
|
||||||
|
# Only include flights booked out today
|
||||||
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate ETA from departed_dt + duration (if both are available)
|
||||||
|
eta = flight.departed_dt
|
||||||
|
if flight.departed_dt and flight.duration:
|
||||||
|
eta = flight.departed_dt + timedelta(minutes=flight.duration)
|
||||||
|
|
||||||
|
arrivals_list.append({
|
||||||
|
'ac_call': flight.callsign or flight.registration,
|
||||||
|
'ac_reg': flight.registration,
|
||||||
|
'ac_type': flight.type,
|
||||||
|
'in_from': None,
|
||||||
|
'eta': eta,
|
||||||
|
'landed_dt': None,
|
||||||
|
'status': 'DEPARTED',
|
||||||
|
'isLocalFlight': True,
|
||||||
|
'flight_type': flight.flight_type.value
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add booked-in arrivals
|
||||||
|
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
|
||||||
|
for arrival in booked_in_arrivals:
|
||||||
|
# Only include BOOKED_IN and LANDED arrivals
|
||||||
|
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
|
||||||
|
continue
|
||||||
|
# For BOOKED_IN, only include those created today
|
||||||
|
if arrival.status == ArrivalStatus.BOOKED_IN:
|
||||||
|
if not (today_start <= arrival.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
# For LANDED, only include those landed today
|
||||||
|
elif arrival.status == ArrivalStatus.LANDED:
|
||||||
|
if not arrival.landed_dt or not (today_start <= arrival.landed_dt < today_end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
arrivals_list.append({
|
||||||
|
'registration': arrival.registration,
|
||||||
|
'callsign': arrival.callsign,
|
||||||
|
'type': arrival.type,
|
||||||
|
'in_from': arrival.in_from,
|
||||||
|
'eta': arrival.eta,
|
||||||
|
'landed_dt': arrival.landed_dt,
|
||||||
|
'status': arrival.status.value,
|
||||||
|
'isBookedIn': True
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrivals_list
|
||||||
|
|
||||||
|
|
||||||
@router.get("/departures", response_model=List[PPR])
|
@router.get("/departures")
|
||||||
async def get_public_departures(db: Session = Depends(get_db)):
|
async def get_public_departures(db: Session = Depends(get_db)):
|
||||||
"""Get today's departures for public display"""
|
"""Get today's departures for public display (PPR, local flights, and departures to other airports)"""
|
||||||
departures = crud_ppr.get_departures_today(db)
|
departures = crud_ppr.get_departures_today(db)
|
||||||
return departures
|
|
||||||
|
# Convert PPR departures to dictionaries
|
||||||
|
departures_list = []
|
||||||
|
for departure in departures:
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': departure.ac_call,
|
||||||
|
'ac_reg': departure.ac_reg,
|
||||||
|
'ac_type': departure.ac_type,
|
||||||
|
'out_to': departure.out_to,
|
||||||
|
'etd': departure.etd,
|
||||||
|
'departed_dt': departure.departed_dt,
|
||||||
|
'status': departure.status.value,
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add local flights with BOOKED_OUT status that were booked out today
|
||||||
|
local_flights = crud_local_flight.get_multi(
|
||||||
|
db,
|
||||||
|
status=LocalFlightStatus.BOOKED_OUT,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert local flights to match the PPR format for display
|
||||||
|
for flight in local_flights:
|
||||||
|
# Only include flights booked out today
|
||||||
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': flight.callsign or flight.registration,
|
||||||
|
'ac_reg': flight.registration,
|
||||||
|
'ac_type': flight.type,
|
||||||
|
'out_to': None,
|
||||||
|
'etd': flight.etd or flight.created_dt,
|
||||||
|
'departed_dt': None,
|
||||||
|
'status': 'BOOKED_OUT',
|
||||||
|
'isLocalFlight': True,
|
||||||
|
'flight_type': flight.flight_type.value,
|
||||||
|
'isDeparture': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add departures to other airports with BOOKED_OUT status
|
||||||
|
departures_to_airports = crud_departure.get_multi(
|
||||||
|
db,
|
||||||
|
status=DepartureStatus.BOOKED_OUT,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert departures to match the format for display
|
||||||
|
for dep in departures_to_airports:
|
||||||
|
# Only include departures booked out today
|
||||||
|
if not (today_start <= dep.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': dep.callsign or dep.registration,
|
||||||
|
'ac_reg': dep.registration,
|
||||||
|
'ac_type': dep.type,
|
||||||
|
'out_to': dep.out_to,
|
||||||
|
'etd': dep.etd or dep.created_dt,
|
||||||
|
'departed_dt': None,
|
||||||
|
'status': 'BOOKED_OUT',
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add departures to other airports with DEPARTED status (taken off today)
|
||||||
|
departed_to_airports = crud_departure.get_multi(
|
||||||
|
db,
|
||||||
|
status=DepartureStatus.DEPARTED,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
for dep in departed_to_airports:
|
||||||
|
# Only include departures that departed today
|
||||||
|
if not dep.departed_dt or not (today_start <= dep.departed_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': dep.callsign or dep.registration,
|
||||||
|
'ac_reg': dep.registration,
|
||||||
|
'ac_type': dep.type,
|
||||||
|
'out_to': dep.out_to,
|
||||||
|
'etd': dep.etd or dep.created_dt,
|
||||||
|
'departed_dt': dep.departed_dt,
|
||||||
|
'status': 'DEPARTED',
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': True
|
||||||
|
})
|
||||||
|
|
||||||
|
return departures_list
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_ui_config():
|
||||||
|
"""Get UI configuration for client-side rendering"""
|
||||||
|
from app.core.config import settings
|
||||||
|
base_color = settings.top_bar_base_color
|
||||||
|
return {
|
||||||
|
"tag": settings.tag,
|
||||||
|
"top_bar_gradient_start": base_color,
|
||||||
|
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
|
||||||
|
"footer_color": darken_color(base_color, 0.2), # Darken for footer
|
||||||
|
"environment": settings.environment
|
||||||
|
}
|
||||||
@@ -5,28 +5,33 @@ from typing import Optional
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# Database settings
|
# Database settings
|
||||||
db_host: str = "db" # Docker service name
|
db_host: str = "db" # Docker service name
|
||||||
db_user: str = "ppr_user"
|
db_user: str
|
||||||
db_password: str = "ppr_password123"
|
db_password: str
|
||||||
db_name: str = "ppr_nextgen"
|
db_name: str
|
||||||
db_port: int = 3306
|
db_port: int = 3306
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
secret_key: str = "your-secret-key-change-this-in-production"
|
secret_key: str
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 30
|
access_token_expire_minutes: int = 30
|
||||||
|
|
||||||
# Mail settings
|
# Mail settings
|
||||||
mail_host: str = "send.one.com"
|
mail_host: str
|
||||||
mail_port: int = 465
|
mail_port: int = 465
|
||||||
mail_username: str = "noreply@swansea-airport.wales"
|
mail_username: str
|
||||||
mail_password: str = "SASAGoForward2155"
|
mail_password: str
|
||||||
mail_from: str = "noreply@swansea-airport.wales"
|
mail_from: str
|
||||||
mail_from_name: str = "Swansea Airport"
|
mail_from_name: str
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
api_v1_str: str = "/api/v1"
|
api_v1_str: str = "/api/v1"
|
||||||
project_name: str = "Airfield PPR API"
|
project_name: str = "Airfield PPR API"
|
||||||
base_url: str = "https://pprdev.swansea-airport.wales"
|
base_url: str
|
||||||
|
|
||||||
|
# UI Configuration
|
||||||
|
tag: str = ""
|
||||||
|
top_bar_base_color: str = "#2c3e50"
|
||||||
|
environment: str = "production" # production, development, staging, etc.
|
||||||
|
|
||||||
# Redis settings (for future use)
|
# Redis settings (for future use)
|
||||||
redis_url: Optional[str] = None
|
redis_url: Optional[str] = None
|
||||||
|
|||||||
48
backend/app/core/email.py
Normal file
@@ -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()
|
||||||
147
backend/app/crud/crud_arrival.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func, desc
|
||||||
|
from datetime import date, datetime
|
||||||
|
from app.models.arrival import Arrival, ArrivalStatus
|
||||||
|
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDArrival:
|
||||||
|
def get(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||||
|
return db.query(Arrival).filter(Arrival.id == arrival_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[ArrivalStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[Arrival]:
|
||||||
|
query = db.query(Arrival)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Arrival.status == status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(Arrival.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(Arrival.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_arrivals_today(self, db: Session) -> List[Arrival]:
|
||||||
|
"""Get today's arrivals (booked in or landed)"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(Arrival).filter(
|
||||||
|
and_(
|
||||||
|
func.date(Arrival.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
Arrival.status == ArrivalStatus.BOOKED_IN,
|
||||||
|
Arrival.status == ArrivalStatus.LANDED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Arrival.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
|
||||||
|
db_obj = Arrival(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=ArrivalStatus.BOOKED_IN
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
|
||||||
|
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
if old_normalized == new_normalized:
|
||||||
|
continue # Skip if datetimes are the same
|
||||||
|
|
||||||
|
if old_value != value:
|
||||||
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log changes in journal
|
||||||
|
for change in changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.ARRIVAL,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
arrival_id: int,
|
||||||
|
status: ArrivalStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[Arrival]:
|
||||||
|
db_obj = self.get(db, arrival_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
if status == ArrivalStatus.LANDED and timestamp:
|
||||||
|
db_obj.landed_dt = timestamp
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.ARRIVAL,
|
||||||
|
arrival_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||||
|
db_obj = self.get(db, arrival_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_obj.status = ArrivalStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
arrival = CRUDArrival()
|
||||||
55
backend/app/crud/crud_circuit.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.circuit import Circuit
|
||||||
|
from app.schemas.circuit import CircuitCreate, CircuitUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDCircuit:
|
||||||
|
def get(self, db: Session, circuit_id: int) -> Optional[Circuit]:
|
||||||
|
return db.query(Circuit).filter(Circuit.id == circuit_id).first()
|
||||||
|
|
||||||
|
def get_by_local_flight(self, db: Session, local_flight_id: int) -> List[Circuit]:
|
||||||
|
"""Get all circuits for a specific local flight"""
|
||||||
|
return db.query(Circuit).filter(
|
||||||
|
Circuit.local_flight_id == local_flight_id
|
||||||
|
).order_by(Circuit.circuit_timestamp).all()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Circuit]:
|
||||||
|
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: CircuitCreate) -> Circuit:
|
||||||
|
db_obj = Circuit(
|
||||||
|
local_flight_id=obj_in.local_flight_id,
|
||||||
|
circuit_timestamp=obj_in.circuit_timestamp
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
|
||||||
|
obj_data = obj_in.dict(exclude_unset=True)
|
||||||
|
for field, value in obj_data.items():
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def delete(self, db: Session, circuit_id: int) -> bool:
|
||||||
|
circuit = self.get(db, circuit_id)
|
||||||
|
if circuit:
|
||||||
|
db.delete(circuit)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
crud_circuit = CRUDCircuit()
|
||||||
147
backend/app/crud/crud_departure.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func, desc
|
||||||
|
from datetime import date, datetime
|
||||||
|
from app.models.departure import Departure, DepartureStatus
|
||||||
|
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDDeparture:
|
||||||
|
def get(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||||
|
return db.query(Departure).filter(Departure.id == departure_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[DepartureStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[Departure]:
|
||||||
|
query = db.query(Departure)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Departure.status == status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(Departure.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(Departure.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(Departure.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_departures_today(self, db: Session) -> List[Departure]:
|
||||||
|
"""Get today's departures (booked out or departed)"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(Departure).filter(
|
||||||
|
and_(
|
||||||
|
func.date(Departure.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
Departure.status == DepartureStatus.BOOKED_OUT,
|
||||||
|
Departure.status == DepartureStatus.DEPARTED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Departure.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
|
||||||
|
db_obj = Departure(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=DepartureStatus.BOOKED_OUT
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
|
||||||
|
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
if old_normalized == new_normalized:
|
||||||
|
continue # Skip if datetimes are the same
|
||||||
|
|
||||||
|
if old_value != value:
|
||||||
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log changes in journal
|
||||||
|
for change in changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.DEPARTURE,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
departure_id: int,
|
||||||
|
status: DepartureStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[Departure]:
|
||||||
|
db_obj = self.get(db, departure_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
if status == DepartureStatus.DEPARTED and timestamp:
|
||||||
|
db_obj.departed_dt = timestamp
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.DEPARTURE,
|
||||||
|
departure_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||||
|
db_obj = self.get(db, departure_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_obj.status = DepartureStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
departure = CRUDDeparture()
|
||||||
@@ -1,35 +1,95 @@
|
|||||||
from typing import List
|
from typing import List, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.ppr import Journal
|
from app.models.journal import JournalEntry, EntityType
|
||||||
from app.schemas.ppr import JournalCreate
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class CRUDJournal:
|
class CRUDJournal:
|
||||||
def create(self, db: Session, obj_in: JournalCreate) -> Journal:
|
"""CRUD operations for the generic journal table.
|
||||||
db_obj = Journal(**obj_in.dict())
|
|
||||||
db.add(db_obj)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_obj)
|
|
||||||
return db_obj
|
|
||||||
|
|
||||||
def get_by_ppr_id(self, db: Session, ppr_id: int) -> List[Journal]:
|
This journal is immutable - entries can only be created (by backend) and queried.
|
||||||
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
|
There are no API endpoints for creating journal entries; the backend logs changes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
def log_change(
|
def log_change(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: int,
|
||||||
|
entry: str,
|
||||||
|
user: str,
|
||||||
|
ip: Optional[str] = None
|
||||||
|
) -> JournalEntry:
|
||||||
|
"""Log a change to an entity. Internal backend use only."""
|
||||||
|
journal_entry = JournalEntry(
|
||||||
|
entity_type=entity_type.value,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entry=entry,
|
||||||
|
user=user,
|
||||||
|
ip=ip,
|
||||||
|
entry_dt=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(journal_entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(journal_entry)
|
||||||
|
return journal_entry
|
||||||
|
|
||||||
|
def get_entity_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: int,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries for a specific entity. Read-only API endpoint."""
|
||||||
|
return db.query(JournalEntry).filter(
|
||||||
|
JournalEntry.entity_type == entity_type.value,
|
||||||
|
JournalEntry.entity_id == entity_id
|
||||||
|
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def get_user_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
user: str,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries created by a specific user."""
|
||||||
|
return db.query(JournalEntry).filter(
|
||||||
|
JournalEntry.user == user
|
||||||
|
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
# Convenience methods for backward compatibility with PPR journal
|
||||||
|
def log_ppr_change(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
ppr_id: int,
|
ppr_id: int,
|
||||||
entry: str,
|
entry: str,
|
||||||
user: str,
|
user: str,
|
||||||
ip: str
|
ip: Optional[str] = None
|
||||||
) -> Journal:
|
) -> JournalEntry:
|
||||||
journal_in = JournalCreate(
|
"""Log a change to a PPR (convenience method)."""
|
||||||
ppr_id=ppr_id,
|
return self.log_change(
|
||||||
|
db=db,
|
||||||
|
entity_type=EntityType.PPR,
|
||||||
|
entity_id=ppr_id,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
user=user,
|
user=user,
|
||||||
ip=ip
|
ip=ip
|
||||||
)
|
)
|
||||||
return self.create(db, journal_in)
|
|
||||||
|
def get_ppr_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
ppr_id: int,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries for a PPR (convenience method)."""
|
||||||
|
return self.get_entity_journal(
|
||||||
|
db=db,
|
||||||
|
entity_type=EntityType.PPR,
|
||||||
|
entity_id=ppr_id,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
journal = CRUDJournal()
|
journal = CRUDJournal()
|
||||||
185
backend/app/crud/crud_local_flight.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func, desc
|
||||||
|
from datetime import date, datetime
|
||||||
|
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||||
|
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.models.circuit import Circuit
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDLocalFlight:
|
||||||
|
def get(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
||||||
|
return db.query(LocalFlight).filter(LocalFlight.id == flight_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[LocalFlightStatus] = None,
|
||||||
|
flight_type: Optional[LocalFlightType] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[LocalFlight]:
|
||||||
|
query = db.query(LocalFlight)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(LocalFlight.status == status)
|
||||||
|
|
||||||
|
if flight_type:
|
||||||
|
query = query.filter(LocalFlight.flight_type == flight_type)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(LocalFlight.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(LocalFlight.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(LocalFlight.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_active_flights(self, db: Session) -> List[LocalFlight]:
|
||||||
|
"""Get currently active (booked out or departed) flights"""
|
||||||
|
return db.query(LocalFlight).filter(
|
||||||
|
or_(
|
||||||
|
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlight.status == LocalFlightStatus.DEPARTED
|
||||||
|
)
|
||||||
|
).order_by(desc(LocalFlight.created_dt)).all()
|
||||||
|
|
||||||
|
def get_departures_today(self, db: Session) -> List[LocalFlight]:
|
||||||
|
"""Get today's departures (booked out or departed)"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(LocalFlight).filter(
|
||||||
|
and_(
|
||||||
|
func.date(LocalFlight.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlight.status == LocalFlightStatus.DEPARTED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(LocalFlight.created_dt).all()
|
||||||
|
|
||||||
|
def get_booked_out_today(self, db: Session) -> List[LocalFlight]:
|
||||||
|
"""Get all flights booked out today"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(LocalFlight).filter(
|
||||||
|
and_(
|
||||||
|
func.date(LocalFlight.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlight.status == LocalFlightStatus.LANDED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(LocalFlight.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
|
||||||
|
db_obj = LocalFlight(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=LocalFlightStatus.BOOKED_OUT
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
|
||||||
|
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
if old_normalized == new_normalized:
|
||||||
|
continue # Skip if datetimes are the same
|
||||||
|
|
||||||
|
if old_value != value:
|
||||||
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log changes in journal
|
||||||
|
for change in changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.LOCAL_FLIGHT,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
flight_id: int,
|
||||||
|
status: LocalFlightStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[LocalFlight]:
|
||||||
|
db_obj = self.get(db, flight_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ensure status is a LocalFlightStatus enum
|
||||||
|
if isinstance(status, str):
|
||||||
|
status = LocalFlightStatus(status)
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
# Set timestamps based on status
|
||||||
|
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||||
|
if status == LocalFlightStatus.DEPARTED:
|
||||||
|
db_obj.departed_dt = current_time
|
||||||
|
elif status == LocalFlightStatus.LANDED:
|
||||||
|
db_obj.landed_dt = current_time
|
||||||
|
# Count circuits from the circuits table and populate the circuits column
|
||||||
|
circuit_count = db.query(func.count(Circuit.id)).filter(
|
||||||
|
Circuit.local_flight_id == flight_id
|
||||||
|
).scalar()
|
||||||
|
db_obj.circuits = circuit_count
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.LOCAL_FLIGHT,
|
||||||
|
flight_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
||||||
|
db_obj = self.get(db, flight_id)
|
||||||
|
if db_obj:
|
||||||
|
db_obj.status = LocalFlightStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
local_flight = CRUDLocalFlight()
|
||||||
172
backend/app/crud/crud_overflight.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func, desc
|
||||||
|
from datetime import date, datetime
|
||||||
|
from app.models.overflight import Overflight, OverflightStatus
|
||||||
|
from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDOverflight:
|
||||||
|
def get(self, db: Session, overflight_id: int) -> Optional[Overflight]:
|
||||||
|
return db.query(Overflight).filter(Overflight.id == overflight_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[OverflightStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[Overflight]:
|
||||||
|
query = db.query(Overflight)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Overflight.status == status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(Overflight.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(Overflight.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(Overflight.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_active_overflights(self, db: Session) -> List[Overflight]:
|
||||||
|
"""Get currently active overflights"""
|
||||||
|
return db.query(Overflight).filter(
|
||||||
|
Overflight.status == OverflightStatus.ACTIVE
|
||||||
|
).order_by(desc(Overflight.created_dt)).all()
|
||||||
|
|
||||||
|
def get_overflights_today(self, db: Session) -> List[Overflight]:
|
||||||
|
"""Get today's overflights"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(Overflight).filter(
|
||||||
|
func.date(Overflight.created_dt) == today
|
||||||
|
).order_by(Overflight.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: OverflightCreate, created_by: str) -> Overflight:
|
||||||
|
db_obj = Overflight(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=OverflightStatus.ACTIVE
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log creation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.OVERFLIGHT,
|
||||||
|
db_obj.id,
|
||||||
|
f"Overflight created: {obj_in.registration} from {obj_in.departure_airfield} to {obj_in.destination_airfield}",
|
||||||
|
created_by,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Overflight, obj_in: OverflightUpdate, user: str = "system", user_ip: Optional[str] = None) -> Overflight:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
|
||||||
|
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
if old_normalized == new_normalized:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if old_value != value:
|
||||||
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log changes in journal
|
||||||
|
for change in changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.OVERFLIGHT,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
overflight_id: int,
|
||||||
|
status: OverflightStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[Overflight]:
|
||||||
|
db_obj = self.get(db, overflight_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ensure status is an OverflightStatus enum
|
||||||
|
if isinstance(status, str):
|
||||||
|
status = OverflightStatus(status)
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
# Set timestamp if transitioning to INACTIVE (QSY'd)
|
||||||
|
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||||
|
if status == OverflightStatus.INACTIVE:
|
||||||
|
db_obj.qsy_dt = current_time
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.OVERFLIGHT,
|
||||||
|
overflight_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, overflight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Overflight]:
|
||||||
|
db_obj = self.get(db, overflight_id)
|
||||||
|
if db_obj:
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = OverflightStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log cancellation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.OVERFLIGHT,
|
||||||
|
overflight_id,
|
||||||
|
f"Status changed from {old_status.value} to CANCELLED",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
overflight = CRUDOverflight()
|
||||||
@@ -2,7 +2,9 @@ from typing import List, Optional
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_, func, desc
|
from sqlalchemy import and_, or_, func, desc
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
import secrets
|
||||||
from app.models.ppr import PPRRecord, PPRStatus
|
from app.models.ppr import PPRRecord, PPRStatus
|
||||||
|
from app.models.journal import EntityType
|
||||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||||
from app.crud.crud_journal import journal as crud_journal
|
from app.crud.crud_journal import journal as crud_journal
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ class CRUDPPR:
|
|||||||
def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]:
|
def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]:
|
||||||
return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first()
|
return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first()
|
||||||
|
|
||||||
|
def get_by_public_token(self, db: Session, token: str) -> Optional[PPRRecord]:
|
||||||
|
return db.query(PPRRecord).filter(PPRRecord.public_token == token).first()
|
||||||
|
|
||||||
def get_multi(
|
def get_multi(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -44,12 +49,17 @@ class CRUDPPR:
|
|||||||
return query.order_by(desc(PPRRecord.submitted_dt)).offset(skip).limit(limit).all()
|
return query.order_by(desc(PPRRecord.submitted_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
def get_arrivals_today(self, db: Session) -> List[PPRRecord]:
|
def get_arrivals_today(self, db: Session) -> List[PPRRecord]:
|
||||||
"""Get today's arrivals"""
|
"""Get today's arrivals - includes aircraft that have arrived and may have departed"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
return db.query(PPRRecord).filter(
|
return db.query(PPRRecord).filter(
|
||||||
and_(
|
and_(
|
||||||
func.date(PPRRecord.eta) == today,
|
func.date(PPRRecord.eta) == today,
|
||||||
PPRRecord.status == PPRStatus.NEW
|
or_(
|
||||||
|
PPRRecord.status == PPRStatus.NEW,
|
||||||
|
PPRRecord.status == PPRStatus.CONFIRMED,
|
||||||
|
PPRRecord.status == PPRStatus.LANDED,
|
||||||
|
PPRRecord.status == PPRStatus.DEPARTED
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).order_by(PPRRecord.eta).all()
|
).order_by(PPRRecord.eta).all()
|
||||||
|
|
||||||
@@ -59,7 +69,10 @@ class CRUDPPR:
|
|||||||
return db.query(PPRRecord).filter(
|
return db.query(PPRRecord).filter(
|
||||||
and_(
|
and_(
|
||||||
func.date(PPRRecord.etd) == today,
|
func.date(PPRRecord.etd) == today,
|
||||||
PPRRecord.status == PPRStatus.LANDED
|
or_(
|
||||||
|
PPRRecord.status == PPRStatus.LANDED,
|
||||||
|
PPRRecord.status == PPRStatus.DEPARTED
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).order_by(PPRRecord.etd).all()
|
).order_by(PPRRecord.etd).all()
|
||||||
|
|
||||||
@@ -67,7 +80,8 @@ class CRUDPPR:
|
|||||||
db_obj = PPRRecord(
|
db_obj = PPRRecord(
|
||||||
**obj_in.dict(),
|
**obj_in.dict(),
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
status=PPRStatus.NEW
|
status=PPRStatus.NEW,
|
||||||
|
public_token=secrets.token_urlsafe(64)
|
||||||
)
|
)
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -76,6 +90,7 @@ class CRUDPPR:
|
|||||||
# Log creation in journal
|
# Log creation in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_change(
|
||||||
db,
|
db,
|
||||||
|
EntityType.PPR,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"PPR created for {db_obj.ac_reg}",
|
f"PPR created for {db_obj.ac_reg}",
|
||||||
created_by,
|
created_by,
|
||||||
@@ -85,11 +100,22 @@ class CRUDPPR:
|
|||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
|
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
update_data = obj_in.dict(exclude_unset=True)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
changes = []
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
old_value = getattr(db_obj, field)
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
|
||||||
|
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
if old_normalized == new_normalized:
|
||||||
|
continue # Skip if datetimes are the same
|
||||||
|
|
||||||
if old_value != value:
|
if old_value != value:
|
||||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
setattr(db_obj, field, value)
|
setattr(db_obj, field, value)
|
||||||
@@ -101,7 +127,7 @@ class CRUDPPR:
|
|||||||
|
|
||||||
# Log changes in journal
|
# Log changes in journal
|
||||||
for change in changes:
|
for change in changes:
|
||||||
crud_journal.log_change(db, db_obj.id, change, user, user_ip)
|
crud_journal.log_ppr_change(db, db_obj.id, change, user, user_ip)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
@@ -133,7 +159,7 @@ class CRUDPPR:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
# Log status change in journal
|
# Log status change in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_ppr_change(
|
||||||
db,
|
db,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"Status changed from {old_status.value} to {status.value}",
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
|||||||
@@ -50,5 +50,14 @@ class CRUDUser:
|
|||||||
# For future use if we add user status
|
# For future use if we add user status
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
|
||||||
|
"""Change a user's password (typically used by admins to reset another user's password)"""
|
||||||
|
hashed_password = get_password_hash(new_password)
|
||||||
|
db_obj.password = hashed_password
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
user = CRUDUser()
|
user = CRUDUser()
|
||||||
@@ -2,9 +2,27 @@ from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconn
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from typing import List
|
from typing import List
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import redis.asyncio as redis
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.api import api_router
|
from app.api.api import api_router
|
||||||
|
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
from app.models.ppr import PPRRecord, User, Airport, Aircraft
|
||||||
|
from app.models.journal import JournalEntry
|
||||||
|
from app.models.local_flight import LocalFlight
|
||||||
|
from app.models.departure import Departure
|
||||||
|
from app.models.arrival import Arrival
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Redis client for pub/sub (cross-worker communication)
|
||||||
|
redis_client = None
|
||||||
|
pubsub = None
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.project_name,
|
title=settings.project_name,
|
||||||
openapi_url=f"{settings.api_v1_str}/openapi.json",
|
openapi_url=f"{settings.api_v1_str}/openapi.json",
|
||||||
@@ -25,28 +43,117 @@ app.add_middleware(
|
|||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: List[WebSocket] = []
|
self.active_connections: List[WebSocket] = []
|
||||||
|
self.redis_listener_task = None
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket):
|
async def connect(self, websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
self.active_connections.append(websocket)
|
self.active_connections.append(websocket)
|
||||||
|
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket):
|
def disconnect(self, websocket: WebSocket):
|
||||||
self.active_connections.remove(websocket)
|
if websocket in self.active_connections:
|
||||||
|
self.active_connections.remove(websocket)
|
||||||
|
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
|
||||||
|
|
||||||
async def send_personal_message(self, message: str, websocket: WebSocket):
|
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||||
await websocket.send_text(message)
|
await websocket.send_text(message)
|
||||||
|
|
||||||
async def broadcast(self, message: dict):
|
async def broadcast_local(self, message_str: str):
|
||||||
message_str = json.dumps(message)
|
"""Broadcast to connections on this worker only"""
|
||||||
|
dead_connections = []
|
||||||
for connection in self.active_connections:
|
for connection in self.active_connections:
|
||||||
try:
|
try:
|
||||||
await connection.send_text(message_str)
|
await connection.send_text(message_str)
|
||||||
except:
|
except Exception as e:
|
||||||
# Connection is dead, remove it
|
logger.warning(f"Failed to send to connection: {e}")
|
||||||
|
dead_connections.append(connection)
|
||||||
|
|
||||||
|
# Remove dead connections
|
||||||
|
for connection in dead_connections:
|
||||||
|
if connection in self.active_connections:
|
||||||
self.active_connections.remove(connection)
|
self.active_connections.remove(connection)
|
||||||
|
|
||||||
|
if dead_connections:
|
||||||
|
logger.info(f"Removed {len(dead_connections)} dead connections")
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict):
|
||||||
|
"""Broadcast via Redis pub/sub to all workers"""
|
||||||
|
message_str = json.dumps(message)
|
||||||
|
print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||||
|
logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if redis_client:
|
||||||
|
await redis_client.publish('ppr_updates', message_str)
|
||||||
|
print(f"✓ Message published to Redis")
|
||||||
|
else:
|
||||||
|
# Fallback to local broadcast if Redis not available
|
||||||
|
print("⚠ Redis not available, falling back to local broadcast")
|
||||||
|
logger.warning("Redis not available, falling back to local broadcast")
|
||||||
|
await self.broadcast_local(message_str)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Failed to publish to Redis: {e}")
|
||||||
|
logger.error(f"Failed to publish to Redis: {e}")
|
||||||
|
# Fallback to local broadcast
|
||||||
|
await self.broadcast_local(message_str)
|
||||||
|
|
||||||
|
async def start_redis_listener(self):
|
||||||
|
"""Listen for Redis pub/sub messages and broadcast to local connections"""
|
||||||
|
global redis_client, pubsub
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to Redis
|
||||||
|
redis_url = settings.redis_url or "redis://redis:6379"
|
||||||
|
print(f"Connecting to Redis at: {redis_url}")
|
||||||
|
redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
|
||||||
|
pubsub = redis_client.pubsub()
|
||||||
|
await pubsub.subscribe('ppr_updates')
|
||||||
|
|
||||||
|
print("✓ Redis listener started for PPR updates")
|
||||||
|
logger.info("Redis listener started for PPR updates")
|
||||||
|
|
||||||
|
async for message in pubsub.listen():
|
||||||
|
if message['type'] == 'message':
|
||||||
|
message_data = message['data']
|
||||||
|
print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||||
|
logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||||
|
await self.broadcast_local(message_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Redis listener error: {e}")
|
||||||
|
logger.error(f"Redis listener error: {e}")
|
||||||
|
await asyncio.sleep(5) # Wait before retry
|
||||||
|
# Retry connection
|
||||||
|
if self.redis_listener_task and not self.redis_listener_task.done():
|
||||||
|
asyncio.create_task(self.start_redis_listener())
|
||||||
|
|
||||||
manager = ConnectionManager()
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Start Redis listener when application starts"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("STARTUP: Starting application and Redis listener...")
|
||||||
|
print("=" * 50)
|
||||||
|
logger.info("Starting application and Redis listener...")
|
||||||
|
manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
"""Clean up Redis connections on shutdown"""
|
||||||
|
logger.info("Shutting down application...")
|
||||||
|
global redis_client, pubsub
|
||||||
|
|
||||||
|
if manager.redis_listener_task:
|
||||||
|
manager.redis_listener_task.cancel()
|
||||||
|
|
||||||
|
if pubsub:
|
||||||
|
await pubsub.unsubscribe('ppr_updates')
|
||||||
|
await pubsub.close()
|
||||||
|
|
||||||
|
if redis_client:
|
||||||
|
await redis_client.close()
|
||||||
|
|
||||||
@app.websocket("/ws/tower-updates")
|
@app.websocket("/ws/tower-updates")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
await manager.connect(websocket)
|
await manager.connect(websocket)
|
||||||
@@ -69,7 +176,29 @@ async def root():
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "healthy", "timestamp": "2024-01-01T00:00:00Z"}
|
"""Health check endpoint with database connectivity verification"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
|
||||||
|
health_status = {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check database connectivity
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
db.close()
|
||||||
|
health_status["database"] = "connected"
|
||||||
|
except Exception as e:
|
||||||
|
health_status["status"] = "unhealthy"
|
||||||
|
health_status["database"] = "disconnected"
|
||||||
|
health_status["error"] = str(e)
|
||||||
|
|
||||||
|
return health_status
|
||||||
|
|
||||||
# Include API router
|
# Include API router
|
||||||
app.include_router(api_router, prefix=settings.api_v1_str)
|
app.include_router(api_router, prefix=settings.api_v1_str)
|
||||||
|
|||||||
30
backend/app/models/arrival.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatus(str, Enum):
|
||||||
|
BOOKED_IN = "BOOKED_IN"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class Arrival(Base):
|
||||||
|
__tablename__ = "arrivals"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
type = Column(String(32), nullable=True)
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
pob = Column(Integer, nullable=False)
|
||||||
|
in_from = Column(String(4), nullable=False, index=True)
|
||||||
|
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
|
||||||
|
eta = Column(DateTime, nullable=True, index=True)
|
||||||
|
landed_dt = Column(DateTime, nullable=True)
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
12
backend/app/models/circuit.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, BigInteger, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Circuit(Base):
|
||||||
|
__tablename__ = "circuits"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
circuit_timestamp = Column(DateTime, nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
30
backend/app/models/departure.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class Departure(Base):
|
||||||
|
__tablename__ = "departures"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
type = Column(String(32), nullable=True)
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
pob = Column(Integer, nullable=False)
|
||||||
|
out_to = Column(String(64), nullable=False, index=True)
|
||||||
|
status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
|
||||||
|
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
|
||||||
|
departed_dt = Column(DateTime, nullable=True) # Actual departure time
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
34
backend/app/models/journal.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Text, DateTime, Index, func
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EntityType(str, PyEnum):
|
||||||
|
"""Entity types that can have journal entries"""
|
||||||
|
PPR = "PPR"
|
||||||
|
LOCAL_FLIGHT = "LOCAL_FLIGHT"
|
||||||
|
ARRIVAL = "ARRIVAL"
|
||||||
|
DEPARTURE = "DEPARTURE"
|
||||||
|
OVERFLIGHT = "OVERFLIGHT"
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(Base):
|
||||||
|
"""
|
||||||
|
Generic journal table for tracking changes across all entity types.
|
||||||
|
Replaces the PPR-specific journal table.
|
||||||
|
"""
|
||||||
|
__tablename__ = "journal"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
|
||||||
|
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the entity
|
||||||
|
entry = Column(Text, nullable=False)
|
||||||
|
user = Column(String(50), nullable=False, index=True)
|
||||||
|
ip = Column(String(45), nullable=True) # Made optional for new entries
|
||||||
|
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
|
|
||||||
|
# Composite index for efficient queries
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_entity_lookup', 'entity_type', 'entity_id'),
|
||||||
|
)
|
||||||
38
backend/app/models/local_flight.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from enum import Enum
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightType(str, Enum):
|
||||||
|
LOCAL = "LOCAL"
|
||||||
|
CIRCUITS = "CIRCUITS"
|
||||||
|
DEPARTURE = "DEPARTURE"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlight(Base):
|
||||||
|
__tablename__ = "local_flights"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
type = Column(String(32), nullable=False) # Aircraft type
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
pob = Column(Integer, nullable=False) # Persons on board
|
||||||
|
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
||||||
|
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
|
||||||
|
duration = Column(Integer, nullable=True) # Duration in minutes
|
||||||
|
circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
|
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
|
||||||
|
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
|
||||||
|
landed_dt = Column(DateTime, nullable=True)
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
28
backend/app/models/overflight.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from enum import Enum
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightStatus(str, Enum):
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
INACTIVE = "INACTIVE"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class Overflight(Base):
|
||||||
|
__tablename__ = "overflights"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
pob = Column(Integer, nullable=True) # Persons on board
|
||||||
|
type = Column(String(32), nullable=True) # Aircraft type
|
||||||
|
departure_airfield = Column(String(64), nullable=True, index=True) # Airfield they departed from
|
||||||
|
destination_airfield = Column(String(64), nullable=True, index=True) # Where they're heading
|
||||||
|
status = Column(SQLEnum(OverflightStatus), nullable=False, default=OverflightStatus.ACTIVE, index=True)
|
||||||
|
call_dt = Column(DateTime, nullable=False, index=True) # Time of initial call
|
||||||
|
qsy_dt = Column(DateTime, nullable=True) # Time of frequency change (QSY)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
@@ -23,25 +23,27 @@ class PPRRecord(Base):
|
|||||||
__tablename__ = "submitted"
|
__tablename__ = "submitted"
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW)
|
status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW, index=True)
|
||||||
ac_reg = Column(String(16), nullable=False)
|
ac_reg = Column(String(16), nullable=False, index=True)
|
||||||
ac_type = Column(String(32), nullable=False)
|
ac_type = Column(String(32), nullable=False)
|
||||||
ac_call = Column(String(16), nullable=True)
|
ac_call = Column(String(16), nullable=True)
|
||||||
captain = Column(String(64), nullable=False)
|
captain = Column(String(64), nullable=False)
|
||||||
fuel = Column(String(16), nullable=True)
|
fuel = Column(String(16), nullable=True)
|
||||||
in_from = Column(String(64), nullable=False)
|
in_from = Column(String(64), nullable=False)
|
||||||
eta = Column(DateTime, nullable=False)
|
eta = Column(DateTime, nullable=False, index=True)
|
||||||
pob_in = Column(Integer, nullable=False)
|
pob_in = Column(Integer, nullable=False)
|
||||||
out_to = Column(String(64), nullable=True)
|
out_to = Column(String(64), nullable=True)
|
||||||
etd = Column(DateTime, nullable=True)
|
etd = Column(DateTime, nullable=True, index=True)
|
||||||
pob_out = Column(Integer, nullable=True)
|
pob_out = Column(Integer, nullable=True)
|
||||||
email = Column(String(128), nullable=True)
|
email = Column(String(128), nullable=True)
|
||||||
phone = Column(String(16), nullable=True)
|
phone = Column(String(16), nullable=True)
|
||||||
notes = Column(String(2000), nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
landed_dt = Column(DateTime, nullable=True)
|
landed_dt = Column(DateTime, nullable=True)
|
||||||
departed_dt = Column(DateTime, nullable=True)
|
departed_dt = Column(DateTime, nullable=True)
|
||||||
created_by = Column(String(16), nullable=True)
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
|
public_token = Column(String(128), nullable=True, unique=True, index=True)
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -51,17 +53,11 @@ class User(Base):
|
|||||||
username = Column(String(50), nullable=False, unique=True, index=True)
|
username = Column(String(50), nullable=False, unique=True, index=True)
|
||||||
password = Column(String(255), nullable=False)
|
password = Column(String(255), nullable=False)
|
||||||
role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY)
|
role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY)
|
||||||
|
email = Column(String(128), nullable=True)
|
||||||
|
full_name = Column(String(100), nullable=True)
|
||||||
class Journal(Base):
|
is_active = Column(Integer, nullable=False, default=1) # Using Integer for BOOLEAN
|
||||||
__tablename__ = "journal"
|
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
ppr_id = Column(Integer, nullable=False, index=True)
|
|
||||||
entry = Column(Text, nullable=False)
|
|
||||||
user = Column(Text, nullable=False)
|
|
||||||
ip = Column(Text, nullable=False)
|
|
||||||
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
class Airport(Base):
|
class Airport(Base):
|
||||||
@@ -84,12 +80,12 @@ class Aircraft(Base):
|
|||||||
__tablename__ = "aircraft"
|
__tablename__ = "aircraft"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
icao24 = Column(String(6), nullable=True)
|
icao24 = Column(String(6), nullable=True, index=True)
|
||||||
registration = Column(String(25), nullable=True, index=True)
|
registration = Column(String(25), nullable=True, index=True)
|
||||||
manufacturer_icao = Column(String(50), nullable=True)
|
manufacturer_icao = Column(String(50), nullable=True)
|
||||||
type_code = Column(String(30), nullable=True)
|
type_code = Column(String(30), nullable=True, index=True)
|
||||||
manufacturer_name = Column(String(255), nullable=True)
|
manufacturer_name = Column(String(255), nullable=True)
|
||||||
model = Column(String(255), nullable=True)
|
model = Column(String(255), nullable=True)
|
||||||
clean_reg = Column(String(25), nullable=True, index=True)
|
clean_reg = Column(String(25), nullable=True, index=True)
|
||||||
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
68
backend/app/schemas/arrival.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatus(str, Enum):
|
||||||
|
BOOKED_IN = "BOOKED_IN"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalBase(BaseModel):
|
||||||
|
registration: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
in_from: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('registration')
|
||||||
|
def validate_registration(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Aircraft registration is required')
|
||||||
|
return v.strip().upper()
|
||||||
|
|
||||||
|
@validator('in_from')
|
||||||
|
def validate_in_from(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Origin airport is required')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@validator('pob')
|
||||||
|
def validate_pob(cls, v):
|
||||||
|
if v is not None and v < 1:
|
||||||
|
raise ValueError('Persons on board must be at least 1')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalCreate(ArrivalBase):
|
||||||
|
eta: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
in_from: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatusUpdate(BaseModel):
|
||||||
|
status: ArrivalStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Arrival(ArrivalBase):
|
||||||
|
id: int
|
||||||
|
status: ArrivalStatus
|
||||||
|
created_dt: datetime
|
||||||
|
eta: Optional[datetime] = None
|
||||||
|
landed_dt: Optional[datetime] = None
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
24
backend/app/schemas/circuit.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitBase(BaseModel):
|
||||||
|
local_flight_id: int
|
||||||
|
circuit_timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitCreate(CircuitBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitUpdate(BaseModel):
|
||||||
|
circuit_timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Circuit(CircuitBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
65
backend/app/schemas/departure.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureBase(BaseModel):
|
||||||
|
registration: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
out_to: str
|
||||||
|
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('registration')
|
||||||
|
def validate_registration(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Aircraft registration is required')
|
||||||
|
return v.strip().upper()
|
||||||
|
|
||||||
|
@validator('out_to')
|
||||||
|
def validate_out_to(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Destination airport is required')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@validator('pob')
|
||||||
|
def validate_pob(cls, v):
|
||||||
|
if v is not None and v < 1:
|
||||||
|
raise ValueError('Persons on board must be at least 1')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureCreate(DepartureBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
out_to: Optional[str] = None
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatusUpdate(BaseModel):
|
||||||
|
status: DepartureStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Departure(DepartureBase):
|
||||||
|
id: int
|
||||||
|
status: DepartureStatus
|
||||||
|
created_dt: datetime
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
28
backend/app/schemas/journal.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryResponse(BaseModel):
|
||||||
|
"""Read-only schema for journal entries"""
|
||||||
|
id: int
|
||||||
|
entity_type: str # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
|
||||||
|
entity_id: int
|
||||||
|
entry: str
|
||||||
|
user: str
|
||||||
|
ip: Optional[str]
|
||||||
|
entry_dt: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class EntityJournalResponse(BaseModel):
|
||||||
|
"""Response containing all journal entries for an entity"""
|
||||||
|
entity_type: str
|
||||||
|
entity_id: int
|
||||||
|
entries: list[JournalEntryResponse]
|
||||||
|
total_entries: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
90
backend/app/schemas/local_flight.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightType(str, Enum):
|
||||||
|
LOCAL = "LOCAL"
|
||||||
|
CIRCUITS = "CIRCUITS"
|
||||||
|
DEPARTURE = "DEPARTURE"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightBase(BaseModel):
|
||||||
|
registration: str
|
||||||
|
type: Optional[str] = None # Aircraft type - optional, can be looked up later
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
flight_type: LocalFlightType
|
||||||
|
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||||
|
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('registration')
|
||||||
|
def validate_registration(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Aircraft registration is required')
|
||||||
|
return v.strip().upper()
|
||||||
|
|
||||||
|
@validator('type', pre=True, always=False)
|
||||||
|
def validate_type(cls, v):
|
||||||
|
if v is None or (isinstance(v, str) and len(v.strip()) == 0):
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('pob')
|
||||||
|
def validate_pob(cls, v):
|
||||||
|
if v is not None and v < 1:
|
||||||
|
raise ValueError('Persons on board must be at least 1')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightCreate(LocalFlightBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
flight_type: Optional[LocalFlightType] = None
|
||||||
|
duration: Optional[int] = None
|
||||||
|
status: Optional[LocalFlightStatus] = None
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
|
circuits: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightStatusUpdate(BaseModel):
|
||||||
|
status: LocalFlightStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightInDBBase(LocalFlightBase):
|
||||||
|
id: int
|
||||||
|
status: LocalFlightStatus
|
||||||
|
created_dt: datetime
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
|
landed_dt: Optional[datetime] = None
|
||||||
|
circuits: Optional[int] = None
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlight(LocalFlightInDBBase):
|
||||||
|
pass
|
||||||
107
backend/app/schemas/overflight.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightStatus(str, Enum):
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
INACTIVE = "INACTIVE"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightBase(BaseModel):
|
||||||
|
registration: str # Using registration as callsign
|
||||||
|
pob: Optional[int] = None
|
||||||
|
type: Optional[str] = None # Aircraft type
|
||||||
|
departure_airfield: Optional[str] = None
|
||||||
|
destination_airfield: Optional[str] = None
|
||||||
|
call_dt: datetime # Time of initial call
|
||||||
|
qsy_dt: Optional[datetime] = None # Time of frequency change
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('registration')
|
||||||
|
def validate_registration(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Aircraft registration is required')
|
||||||
|
return v.strip().upper()
|
||||||
|
|
||||||
|
@validator('type')
|
||||||
|
def validate_type(cls, v):
|
||||||
|
if v and len(v.strip()) > 0:
|
||||||
|
return v.strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('departure_airfield')
|
||||||
|
def validate_departure_airfield(cls, v):
|
||||||
|
if v and len(v.strip()) > 0:
|
||||||
|
return v.strip().upper()
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('destination_airfield')
|
||||||
|
def validate_destination_airfield(cls, v):
|
||||||
|
if v and len(v.strip()) > 0:
|
||||||
|
return v.strip().upper()
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('pob')
|
||||||
|
def validate_pob(cls, v):
|
||||||
|
if v is not None and v < 1:
|
||||||
|
raise ValueError('Persons on board must be at least 1')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightCreate(OverflightBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightUpdate(BaseModel):
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
departure_airfield: Optional[str] = None
|
||||||
|
destination_airfield: Optional[str] = None
|
||||||
|
call_dt: Optional[datetime] = None
|
||||||
|
qsy_dt: Optional[datetime] = None
|
||||||
|
status: Optional[OverflightStatus] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('type')
|
||||||
|
def validate_type(cls, v):
|
||||||
|
if v is not None and len(v.strip()) == 0:
|
||||||
|
return None
|
||||||
|
return v.strip() if v else v
|
||||||
|
|
||||||
|
@validator('departure_airfield')
|
||||||
|
def validate_departure_airfield(cls, v):
|
||||||
|
if v is not None and len(v.strip()) == 0:
|
||||||
|
return None
|
||||||
|
return v.strip().upper() if v else v
|
||||||
|
|
||||||
|
@validator('destination_airfield')
|
||||||
|
def validate_destination_airfield(cls, v):
|
||||||
|
if v is not None and len(v.strip()) == 0:
|
||||||
|
return None
|
||||||
|
return v.strip().upper() if v else v
|
||||||
|
|
||||||
|
@validator('pob')
|
||||||
|
def validate_pob(cls, v):
|
||||||
|
if v is not None and v < 1:
|
||||||
|
raise ValueError('Persons on board must be at least 1')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class OverflightStatusUpdate(BaseModel):
|
||||||
|
status: OverflightStatus
|
||||||
|
qsy_dt: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Overflight(OverflightBase):
|
||||||
|
id: int
|
||||||
|
status: OverflightStatus
|
||||||
|
created_dt: datetime
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -96,6 +96,25 @@ class PPR(PPRInDBBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PPRPublic(BaseModel):
|
||||||
|
"""Public schema for arrivals/departures board - excludes sensitive data"""
|
||||||
|
id: int
|
||||||
|
status: PPRStatus
|
||||||
|
ac_reg: str
|
||||||
|
ac_type: str
|
||||||
|
ac_call: Optional[str] = None
|
||||||
|
in_from: str
|
||||||
|
eta: datetime
|
||||||
|
out_to: Optional[str] = None
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
landed_dt: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
|
submitted_dt: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class PPRInDB(PPRInDBBase):
|
class PPRInDB(PPRInDBBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -116,6 +135,11 @@ class UserUpdate(BaseModel):
|
|||||||
role: Optional[UserRole] = None
|
role: Optional[UserRole] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePassword(BaseModel):
|
||||||
|
"""Schema for admin-initiated password changes"""
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserInDBBase(UserBase):
|
class UserInDBBase(UserBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@@ -135,6 +159,7 @@ class UserInDB(UserInDBBase):
|
|||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
expires_in: int # Token expiry in seconds
|
||||||
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
|
|||||||
19
backend/app/templates/ppr_cancelled.html
Normal 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>
|
||||||
21
backend/app/templates/ppr_submitted.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>PPR Submitted</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>PPR Submitted Successfully</h1>
|
||||||
|
<p>Dear {{ name }},</p>
|
||||||
|
<p>Your Prior Permission Request (PPR) has been submitted.</p>
|
||||||
|
<p><strong>PPR Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Aircraft: {{ aircraft }}</li>
|
||||||
|
<li>Arrival: {{ arrival_time }}</li>
|
||||||
|
<li>Departure: {{ departure_time }}</li>
|
||||||
|
<li>Purpose: {{ purpose }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
|
||||||
|
<p>You will receive further updates via email.</p>
|
||||||
|
<p>Best regards,<br>Swansea Airport Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
190
backend/entrypoint.sh
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Docker entrypoint script for PPR API
|
||||||
|
# Handles database migrations and data seeding automatically
|
||||||
|
|
||||||
|
# Note: We don't use 'set -e' here because we need to handle specific exit codes from Python scripts
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "PPR API Container Starting"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "Waiting for database to be ready..."
|
||||||
|
python3 << EOF
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
max_retries = 30
|
||||||
|
retry_interval = 2
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
try:
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
print("✓ Database is ready")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
if i < max_retries - 1:
|
||||||
|
print(f"Database not ready yet (attempt {i+1}/{max_retries}), waiting...")
|
||||||
|
time.sleep(retry_interval)
|
||||||
|
else:
|
||||||
|
print(f"✗ Database connection failed after {max_retries} attempts: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to connect to database. Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if this is a fresh database or needs migration
|
||||||
|
echo ""
|
||||||
|
echo "Checking database state..."
|
||||||
|
|
||||||
|
python3 << EOF
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import create_engine, text, inspect
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
# Check if any tables exist
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if not tables:
|
||||||
|
print("✓ Fresh database detected - will initialize")
|
||||||
|
sys.exit(2) # Signal fresh database
|
||||||
|
elif 'alembic_version' not in tables:
|
||||||
|
print("✓ Existing database without migration tracking - will stamp")
|
||||||
|
sys.exit(3) # Signal existing database needs stamping
|
||||||
|
else:
|
||||||
|
print("✓ Database has migration tracking - will check for updates")
|
||||||
|
sys.exit(0) # Normal migration check
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error checking database: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
DB_STATE=$?
|
||||||
|
|
||||||
|
if [ $DB_STATE -eq 2 ]; then
|
||||||
|
# Fresh database - run initial migration
|
||||||
|
echo ""
|
||||||
|
echo "Initializing fresh database..."
|
||||||
|
cd /app
|
||||||
|
alembic upgrade head
|
||||||
|
echo "✓ Database schema created"
|
||||||
|
|
||||||
|
# Seed reference data
|
||||||
|
echo ""
|
||||||
|
echo "Loading reference data..."
|
||||||
|
python3 /app/seed_data.py
|
||||||
|
echo "✓ Reference data loaded"
|
||||||
|
|
||||||
|
elif [ $DB_STATE -eq 3 ]; then
|
||||||
|
# Existing database without Alembic - stamp it
|
||||||
|
echo ""
|
||||||
|
echo "Stamping existing database with migration version..."
|
||||||
|
cd /app
|
||||||
|
alembic stamp head
|
||||||
|
echo "✓ Database stamped"
|
||||||
|
|
||||||
|
# Check if reference data exists
|
||||||
|
python3 << EOF
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
airport_count = conn.execute(text("SELECT COUNT(*) FROM airports")).fetchone()[0]
|
||||||
|
aircraft_count = conn.execute(text("SELECT COUNT(*) FROM aircraft")).fetchone()[0]
|
||||||
|
|
||||||
|
if airport_count == 0 or aircraft_count == 0:
|
||||||
|
print("Reference data missing - will load")
|
||||||
|
exit(10)
|
||||||
|
else:
|
||||||
|
print(f"Reference data exists (airports: {airport_count}, aircraft: {aircraft_count})")
|
||||||
|
exit(0)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 10 ]; then
|
||||||
|
echo "Loading reference data..."
|
||||||
|
python3 /app/seed_data.py
|
||||||
|
echo "✓ Reference data loaded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [ $DB_STATE -eq 0 ]; then
|
||||||
|
# Database with Alembic tracking - check for pending migrations
|
||||||
|
echo ""
|
||||||
|
echo "Checking for pending migrations..."
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
# Get current and head revisions (handle both hash and named revisions)
|
||||||
|
CURRENT=$(alembic current 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
|
||||||
|
HEAD=$(alembic heads 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
|
||||||
|
|
||||||
|
echo " Current: $CURRENT"
|
||||||
|
echo " Target: $HEAD"
|
||||||
|
|
||||||
|
if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then
|
||||||
|
echo "✓ Pending migrations detected"
|
||||||
|
echo "Applying migrations..."
|
||||||
|
alembic upgrade head
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Migrations applied successfully"
|
||||||
|
else
|
||||||
|
echo "✗ Migration failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ Database is up to date"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if reference data needs to be loaded
|
||||||
|
python3 << EOF
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
airport_count = conn.execute(text("SELECT COUNT(*) FROM airports")).fetchone()[0]
|
||||||
|
aircraft_count = conn.execute(text("SELECT COUNT(*) FROM aircraft")).fetchone()[0]
|
||||||
|
|
||||||
|
if airport_count == 0 or aircraft_count == 0:
|
||||||
|
print("Reference data missing - will load")
|
||||||
|
exit(10)
|
||||||
|
else:
|
||||||
|
print(f"Reference data exists (airports: {airport_count}, aircraft: {aircraft_count})")
|
||||||
|
exit(0)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 10 ]; then
|
||||||
|
echo "Loading reference data..."
|
||||||
|
python3 /app/seed_data.py
|
||||||
|
echo "✓ Reference data loaded"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ Database check failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo "Starting Application Server"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start the application with appropriate settings
|
||||||
|
if [ "${ENVIRONMENT}" = "production" ]; then
|
||||||
|
echo "Starting in PRODUCTION mode with multiple workers..."
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${WORKERS:-4}
|
||||||
|
else
|
||||||
|
echo "Starting in DEVELOPMENT mode with auto-reload..."
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
fi
|
||||||
237
backend/populate_test_data.py
Executable file
@@ -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()
|
||||||
@@ -15,3 +15,5 @@ pytest==7.4.3
|
|||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
|
aiosmtplib==3.0.1
|
||||||
|
jinja2==3.1.2
|
||||||
174
backend/seed_data.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed reference data into the database
|
||||||
|
Loads airport and aircraft data from CSV files
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ppr import Airport, Aircraft
|
||||||
|
|
||||||
|
def load_airports(db, csv_path):
|
||||||
|
"""Load airport data from CSV"""
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f" ⚠ Airport data file not found: {csv_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Check if data already exists
|
||||||
|
existing_count = db.query(Airport).count()
|
||||||
|
if existing_count > 0:
|
||||||
|
print(f" ⚠ Airport data already exists ({existing_count} records), skipping")
|
||||||
|
return existing_count
|
||||||
|
|
||||||
|
print(f" Loading airports from {csv_path}...")
|
||||||
|
loaded = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||||
|
reader = csv.reader(f) # CSV has no headers
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 4:
|
||||||
|
continue # Skip invalid rows
|
||||||
|
airport = Airport(
|
||||||
|
icao=row[0].strip('"'),
|
||||||
|
iata=row[1].strip('"') if row[1].strip('"') else None,
|
||||||
|
name=row[2].strip('"'),
|
||||||
|
country=row[3].strip('"')
|
||||||
|
)
|
||||||
|
batch.append(airport)
|
||||||
|
loaded += 1
|
||||||
|
|
||||||
|
# Commit in batches of 1000
|
||||||
|
if len(batch) >= 1000:
|
||||||
|
db.bulk_save_objects(batch)
|
||||||
|
db.commit()
|
||||||
|
batch = []
|
||||||
|
print(f" Loaded {loaded} airports...", end='\r')
|
||||||
|
|
||||||
|
# Commit remaining
|
||||||
|
if batch:
|
||||||
|
db.bulk_save_objects(batch)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f" ✓ Loaded {loaded} airport records" + " " * 20)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def load_aircraft(db, csv_path):
|
||||||
|
"""Load aircraft data from CSV"""
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f" ⚠ Aircraft data file not found: {csv_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Check if data already exists
|
||||||
|
existing_count = db.query(Aircraft).count()
|
||||||
|
if existing_count > 0:
|
||||||
|
print(f" ⚠ Aircraft data already exists ({existing_count} records), skipping")
|
||||||
|
return existing_count
|
||||||
|
|
||||||
|
print(f" Loading aircraft from {csv_path}...")
|
||||||
|
loaded = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||||
|
reader = csv.reader(f) # CSV has no headers
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 6:
|
||||||
|
continue # Skip invalid rows
|
||||||
|
aircraft = Aircraft(
|
||||||
|
icao24=row[0].strip('"') if row[0].strip('"') else None,
|
||||||
|
registration=row[1].strip('"') if row[1].strip('"') else None,
|
||||||
|
manufacturer_icao=row[2].strip('"') if row[2].strip('"') else None,
|
||||||
|
type_code=row[3].strip('"') if row[3].strip('"') else None,
|
||||||
|
manufacturer_name=row[4].strip('"') if row[4].strip('"') else None,
|
||||||
|
model=row[5].strip('"') if row[5].strip('"') else None
|
||||||
|
)
|
||||||
|
batch.append(aircraft)
|
||||||
|
loaded += 1
|
||||||
|
|
||||||
|
# Commit in batches of 1000
|
||||||
|
if len(batch) >= 1000:
|
||||||
|
db.bulk_save_objects(batch)
|
||||||
|
db.commit()
|
||||||
|
batch = []
|
||||||
|
print(f" Loaded {loaded} aircraft...", end='\r')
|
||||||
|
|
||||||
|
# Commit remaining
|
||||||
|
if batch:
|
||||||
|
db.bulk_save_objects(batch)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f" ✓ Loaded {loaded} aircraft records" + " " * 20)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main seeding function"""
|
||||||
|
print("Starting data seeding process...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create database connection
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
db = Session()
|
||||||
|
|
||||||
|
# Determine CSV paths - check multiple locations
|
||||||
|
base_paths = [
|
||||||
|
Path('/app/../db-init'), # Docker mounted volume
|
||||||
|
Path('/app/db-init'), # If copied into container
|
||||||
|
Path('./db-init'), # Current directory
|
||||||
|
Path('../db-init'), # Parent directory
|
||||||
|
]
|
||||||
|
|
||||||
|
airport_csv = None
|
||||||
|
aircraft_csv = None
|
||||||
|
|
||||||
|
for base in base_paths:
|
||||||
|
if base.exists():
|
||||||
|
potential_airport = base / 'airports_data_clean.csv'
|
||||||
|
potential_aircraft = base / 'aircraft_data.csv'
|
||||||
|
|
||||||
|
if potential_airport.exists() and not airport_csv:
|
||||||
|
airport_csv = str(potential_airport)
|
||||||
|
if potential_aircraft.exists() and not aircraft_csv:
|
||||||
|
aircraft_csv = str(potential_aircraft)
|
||||||
|
|
||||||
|
if airport_csv and aircraft_csv:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
airports_loaded = 0
|
||||||
|
aircraft_loaded = 0
|
||||||
|
|
||||||
|
if airport_csv:
|
||||||
|
airports_loaded = load_airports(db, airport_csv)
|
||||||
|
else:
|
||||||
|
print(" ⚠ No airport CSV file found")
|
||||||
|
|
||||||
|
if aircraft_csv:
|
||||||
|
aircraft_loaded = load_aircraft(db, aircraft_csv)
|
||||||
|
else:
|
||||||
|
print(" ⚠ No aircraft CSV file found")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(f"Seeding complete:")
|
||||||
|
print(f" Airports: {airports_loaded:,}")
|
||||||
|
print(f" Aircraft: {aircraft_loaded:,}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error during seeding: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
9
db-init/Dockerfile
Normal file
@@ -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
|
||||||
|
Can't render this file because it is too large.
|
|
Can't render this file because it is too large.
|
@@ -41,6 +41,7 @@ CREATE TABLE submitted (
|
|||||||
departed_dt DATETIME DEFAULT NULL,
|
departed_dt DATETIME DEFAULT NULL,
|
||||||
created_by VARCHAR(16) DEFAULT NULL,
|
created_by VARCHAR(16) DEFAULT NULL,
|
||||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
-- Indexes for better performance
|
-- Indexes for better performance
|
||||||
@@ -49,7 +50,8 @@ CREATE TABLE submitted (
|
|||||||
INDEX idx_etd (etd),
|
INDEX idx_etd (etd),
|
||||||
INDEX idx_ac_reg (ac_reg),
|
INDEX idx_ac_reg (ac_reg),
|
||||||
INDEX idx_submitted_dt (submitted_dt),
|
INDEX idx_submitted_dt (submitted_dt),
|
||||||
INDEX idx_created_by (created_by)
|
INDEX idx_created_by (created_by),
|
||||||
|
INDEX idx_public_token (public_token)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Activity journal table with foreign key
|
-- Activity journal table with foreign key
|
||||||
95
docker-compose.prod.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Production docker-compose configuration
|
||||||
|
# This uses an external database and optimized settings
|
||||||
|
|
||||||
|
services:
|
||||||
|
# FastAPI Backend
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||||
|
API_V1_STR: ${API_V1_STR}
|
||||||
|
PROJECT_NAME: ${PROJECT_NAME}
|
||||||
|
MAIL_HOST: ${MAIL_HOST}
|
||||||
|
MAIL_PORT: ${MAIL_PORT}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
BASE_URL: ${BASE_URL}
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TAG: ${TAG}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
ENVIRONMENT: production
|
||||||
|
WORKERS: "4"
|
||||||
|
ports:
|
||||||
|
- "${API_PORT_EXTERNAL}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Redis for caching (optional)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
# Nginx web server for public frontend
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
BASE_URL: ${BASE_URL}
|
||||||
|
command: >
|
||||||
|
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
|
||||||
|
nginx -g 'daemon off;'"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT_EXTERNAL}:80"
|
||||||
|
volumes:
|
||||||
|
- ./web:/usr/share/nginx/html
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app_network:
|
||||||
|
driver: bridge
|
||||||
@@ -1,26 +1,19 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# MySQL Database
|
|
||||||
db:
|
db:
|
||||||
image: mysql:8.0
|
build: ./db-init
|
||||||
container_name: ppr_nextgen_db
|
container_name: ppr_nextgen_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: rootpassword123
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
MYSQL_DATABASE: ppr_nextgen
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
MYSQL_USER: ppr_user
|
MYSQL_USER: ${DB_USER}
|
||||||
MYSQL_PASSWORD: ppr_password123
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
|
||||||
- "3307:3306" # Use different port to avoid conflicts
|
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
- ./init_db.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
|
||||||
- ./02-import-data.sql:/docker-entrypoint-initdb.d/02-import-data.sql
|
|
||||||
- ./airports_data_clean.csv:/var/lib/mysql-files/airports_data.csv
|
|
||||||
- ./aircraft_data.csv:/var/lib/mysql-files/aircraft_data.csv
|
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
|
||||||
# FastAPI Backend
|
# FastAPI Backend
|
||||||
api:
|
api:
|
||||||
@@ -28,49 +21,63 @@ services:
|
|||||||
container_name: ppr_nextgen_api
|
container_name: ppr_nextgen_api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: ${DB_HOST}
|
||||||
DB_USER: ppr_user
|
DB_USER: ${DB_USER}
|
||||||
DB_PASSWORD: ppr_password123
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_NAME: ppr_nextgen
|
DB_NAME: ${DB_NAME}
|
||||||
DB_PORT: 3306
|
DB_PORT: ${DB_PORT}
|
||||||
SECRET_KEY: super-secret-key-for-nextgen-ppr-system-change-in-production
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||||
API_V1_STR: /api/v1
|
API_V1_STR: ${API_V1_STR}
|
||||||
PROJECT_NAME: "Airfield PPR API NextGen"
|
PROJECT_NAME: ${PROJECT_NAME}
|
||||||
|
MAIL_HOST: ${MAIL_HOST}
|
||||||
|
MAIL_PORT: ${MAIL_PORT}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
BASE_URL: ${BASE_URL}
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TOWER_NAME: ${TOWER_NAME}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT}
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000" # Use different port to avoid conflicts with existing system
|
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
- public_network
|
||||||
|
|
||||||
|
# Nginx web server for public frontend
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BASE_URL: ${BASE_URL}
|
||||||
|
command: >
|
||||||
|
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
|
||||||
|
nginx -g 'daemon off;'"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT_EXTERNAL}:80"
|
||||||
|
volumes:
|
||||||
|
- ./web:/usr/share/nginx/html
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- public_network
|
||||||
|
|
||||||
# Redis for caching (optional for now)
|
# Redis for caching (optional for now)
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: ppr_nextgen_redis
|
container_name: ppr_nextgen_redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "6380:6379" # Use different port
|
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
|
||||||
# Nginx web server for public frontend
|
|
||||||
web:
|
|
||||||
image: nginx:alpine
|
|
||||||
container_name: ppr_nextgen_web
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8082:80" # Public web interface
|
|
||||||
volumes:
|
|
||||||
- ./web:/usr/share/nginx/html
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
networks:
|
|
||||||
- ppr_network
|
|
||||||
|
|
||||||
# phpMyAdmin for database management
|
# phpMyAdmin for database management
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
@@ -78,21 +85,24 @@ services:
|
|||||||
container_name: ppr_nextgen_phpmyadmin
|
container_name: ppr_nextgen_phpmyadmin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: ${PMA_HOST}
|
||||||
PMA_PORT: 3306
|
PMA_PORT: ${DB_PORT}
|
||||||
PMA_USER: ppr_user
|
PMA_USER: ${DB_USER}
|
||||||
PMA_PASSWORD: ppr_password123
|
PMA_PASSWORD: ${DB_PASSWORD}
|
||||||
UPLOAD_LIMIT: 50M
|
UPLOAD_LIMIT: ${UPLOAD_LIMIT}
|
||||||
ports:
|
ports:
|
||||||
- "8083:80" # phpMyAdmin web interface
|
- "${PMA_PORT_EXTERNAL}:80" # phpMyAdmin web interface
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
- public_network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ppr_network:
|
private_network:
|
||||||
|
driver: bridge
|
||||||
|
public_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
29
nginx.conf
@@ -29,9 +29,24 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Allow ppr.html to be embedded in iframes from any origin
|
||||||
|
location = /ppr.html {
|
||||||
|
add_header X-Frame-Options "ALLOWALL" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors *" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve HTML files without .html extension (e.g., /admin -> admin.html)
|
||||||
|
location ~ ^/([a-zA-Z0-9_-]+)$ {
|
||||||
|
try_files /$1.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ =404;
|
||||||
|
# Apply X-Frame-Options to other files
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy API requests to FastAPI backend
|
# Proxy API requests to FastAPI backend
|
||||||
@@ -53,13 +68,21 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket timeout settings (prevent connection drops)
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
|
||||||
|
# Additional WebSocket connection settings
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
# Default CSP for other files
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self'" always;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
16
populate_test_data.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to populate test data in the PPR database
|
||||||
|
# Run this from the nextgen directory
|
||||||
|
|
||||||
|
echo "Populating test data..."
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "docker-compose.yml" ]; then
|
||||||
|
echo "Error: Please run this script from the nextgen directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the population script in the backend container
|
||||||
|
sudo docker compose exec api python populate_test_data.py
|
||||||
|
|
||||||
|
echo "Test data population complete!"
|
||||||
762
web/admin.css
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
padding-bottom: 40px; /* Make room for footer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-buttons {
|
||||||
|
order: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar .user-info {
|
||||||
|
order: 3;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
background-color: white;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a {
|
||||||
|
color: #333;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a:first-child {
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a:last-child {
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar .user-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: #f39c12;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select, .filter-group input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ppr-table {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-collapsible {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-collapsible:hover {
|
||||||
|
background: #3d5a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.new { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
|
||||||
|
.status.landed { background: #fff3e0; color: #ef6c00; }
|
||||||
|
.status.departed { background: #fce4ec; color: #c2185b; }
|
||||||
|
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||||
|
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
cursor: help;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip .tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 300px;
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip .tooltip-text::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: -5px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent #333 transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .notes-tooltip:hover .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form .form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entries {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aircraft Lookup Styles */
|
||||||
|
#aircraft-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-match {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
border: 1px solid #c3e6c3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Airport Lookup Styles */
|
||||||
|
#arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-match {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
border: 1px solid #c3e6c3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-name {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-location {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unified Lookup Styles */
|
||||||
|
.lookup-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option-selected {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
padding-left: calc(0.5rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option-selected:hover {
|
||||||
|
background-color: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-name {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-location {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
4660
web/admin.html
27
web/assets/bell.svg
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Bell body -->
|
||||||
|
<path d="M 30,40 Q 30,35 35,35 L 65,35 Q 70,35 70,40 Q 70,60 50,70 Q 30,60 30,40" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Bell shine/highlight -->
|
||||||
|
<ellipse cx="45" cy="45" rx="8" ry="6" fill="#FFED4E" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Bell clapper -->
|
||||||
|
<circle cx="50" cy="65" r="4" fill="#8B4513"/>
|
||||||
|
<path d="M 50,65 Q 48,75 47,85" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Top of bell (rope/hanging part) -->
|
||||||
|
<rect x="47" y="25" width="6" height="10" fill="#DAA520" rx="2"/>
|
||||||
|
|
||||||
|
<!-- Loop -->
|
||||||
|
<path d="M 48,25 Q 40,20 50,15 Q 60,20 52,25" stroke="#DAA520" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Decorative berries around bell -->
|
||||||
|
<circle cx="25" cy="50" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="75" cy="50" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="28" cy="60" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="72" cy="60" r="2" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Holly leaves -->
|
||||||
|
<path d="M 20,45 L 18,48 L 20,50 L 18,52 L 20,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 80,45 L 82,48 L 80,50 L 82,52 L 80,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
10
web/assets/candycane.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Candy cane curve -->
|
||||||
|
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#E74C3C" stroke-width="12" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- White stripe -->
|
||||||
|
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#FFFFFF" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="8,8"/>
|
||||||
|
|
||||||
|
<!-- Highlight -->
|
||||||
|
<path d="M 48,15 Q 32,32 32,60 Q 32,88 48,98" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 546 B |
BIN
web/assets/flightImg.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
25
web/assets/gift.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Box -->
|
||||||
|
<rect x="15" y="30" width="70" height="70" fill="#E74C3C" stroke="#C0392B" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Box lid/3D effect -->
|
||||||
|
<polygon points="15,30 25,20 85,20 75,30" fill="#C0392B"/>
|
||||||
|
<polygon points="75,30 85,20 85,90 75,100" fill="#A93226"/>
|
||||||
|
|
||||||
|
<!-- Ribbon vertical -->
|
||||||
|
<rect x="42" y="20" width="16" height="85" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Ribbon horizontal -->
|
||||||
|
<rect x="10" y="57" width="80" height="16" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Bow on top -->
|
||||||
|
<ellipse cx="35" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
<ellipse cx="65" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
<circle cx="50" cy="20" r="5" fill="#DAA520"/>
|
||||||
|
|
||||||
|
<!-- Pattern on box -->
|
||||||
|
<circle cx="30" cy="50" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="70" cy="60" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="50" cy="75" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="35" cy="80" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
web/assets/logo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
39
web/assets/reindeer.svg
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Antlers -->
|
||||||
|
<path d="M 35,25 Q 25,10 20,5" stroke="#8B4513" stroke-width="3" fill="none"/>
|
||||||
|
<path d="M 65,25 Q 75,10 80,5" stroke="#8B4513" stroke-width="3" fill="none"/>
|
||||||
|
<path d="M 33,22 Q 22,15 15,8" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M 67,22 Q 78,15 85,8" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="35" r="15" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Ears -->
|
||||||
|
<ellipse cx="38" cy="22" rx="5" ry="8" fill="#8B4513"/>
|
||||||
|
<ellipse cx="62" cy="22" rx="5" ry="8" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle cx="45" cy="32" r="2" fill="#000000"/>
|
||||||
|
<circle cx="55" cy="32" r="2" fill="#000000"/>
|
||||||
|
|
||||||
|
<!-- Nose (red) -->
|
||||||
|
<circle cx="50" cy="40" r="4" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path d="M 48,45 Q 50,47 52,45" stroke="#000000" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Neck -->
|
||||||
|
<rect x="43" y="48" width="14" height="8" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<ellipse cx="50" cy="70" rx="20" ry="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="35" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="45" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="55" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="65" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Tail -->
|
||||||
|
<circle cx="68" cy="65" r="5" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
37
web/assets/santa.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Santa hat -->
|
||||||
|
<polygon points="20,20 80,20 75,35 25,35" fill="#E74C3C"/>
|
||||||
|
<circle cx="50" cy="18" r="8" fill="#E74C3C"/>
|
||||||
|
<circle cx="77" cy="28" r="6" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- Face -->
|
||||||
|
<circle cx="50" cy="50" r="18" fill="#F5DEB3"/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle cx="42" cy="45" r="2.5" fill="#000000"/>
|
||||||
|
<circle cx="58" cy="45" r="2.5" fill="#000000"/>
|
||||||
|
|
||||||
|
<!-- Nose -->
|
||||||
|
<circle cx="50" cy="52" r="2" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Beard -->
|
||||||
|
<path d="M 35,58 Q 35,65 50,68 Q 65,65 65,58" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path d="M 42,60 Q 50,63 58,60" stroke="#000000" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="35" y="68" width="30" height="25" rx="5" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Belt -->
|
||||||
|
<rect x="32" y="85" width="36" height="4" fill="#000000"/>
|
||||||
|
<circle cx="68" cy="87" r="2.5" fill="#FFD700"/>
|
||||||
|
|
||||||
|
<!-- Arms -->
|
||||||
|
<rect x="15" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
|
||||||
|
<rect x="65" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
|
||||||
|
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="40" y="93" width="8" height="20" fill="#000000"/>
|
||||||
|
<rect x="52" y="93" width="8" height="20" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
17
web/assets/tree.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Tree trunk -->
|
||||||
|
<rect x="40" y="80" width="20" height="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Tree layers -->
|
||||||
|
<polygon points="50,10 20,50 30,50 10,80 40,80 5,110 50,110 95,110 60,80 90,80 70,50 80,50" fill="#228B22"/>
|
||||||
|
|
||||||
|
<!-- Tree highlights -->
|
||||||
|
<circle cx="50" cy="35" r="4" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="35" cy="55" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="65" cy="60" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="45" cy="80" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="55" cy="90" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Star on top -->
|
||||||
|
<polygon points="50,5 55,15 65,15 57,20 60,30 50,25 40,30 43,20 35,15 45,15" fill="#FFD700"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 758 B |
792
web/edit.html
Normal file
@@ -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>
|
||||||
1070
web/index.html
503
web/lookups.js
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* Lookup Utilities - Reusable functions for aircraft and airport lookups
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format aircraft registration based on UK rules
|
||||||
|
* - 5 alphabetic chars: add hyphen after first char (GIVYY -> G-IVYY)
|
||||||
|
* - Otherwise: just uppercase (N123AD -> N123AD)
|
||||||
|
*/
|
||||||
|
function formatAircraftRegistration(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const cleaned = input.trim().toUpperCase();
|
||||||
|
|
||||||
|
// If exactly 5 characters and all alphabetic, add hyphen
|
||||||
|
if (cleaned.length === 5 && /^[A-Z]{5}$/.test(cleaned)) {
|
||||||
|
return cleaned[0] + '-' + cleaned.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise just return uppercase version
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reusable lookup handler
|
||||||
|
* @param {string} fieldId - ID of the input field
|
||||||
|
* @param {string} resultsId - ID of the results container
|
||||||
|
* @param {function} selectCallback - Function to call when item is selected
|
||||||
|
* @param {object} options - Additional options (minLength, debounceMs, etc.)
|
||||||
|
*/
|
||||||
|
function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||||
|
const defaults = {
|
||||||
|
minLength: 2,
|
||||||
|
debounceMs: 300,
|
||||||
|
isAirport: false,
|
||||||
|
isAircraft: false,
|
||||||
|
maxResults: 10
|
||||||
|
};
|
||||||
|
const config = { ...defaults, ...options };
|
||||||
|
let debounceTimeout;
|
||||||
|
let currentResults = [];
|
||||||
|
let selectedIndex = -1;
|
||||||
|
let keydownHandlerAttached = false;
|
||||||
|
|
||||||
|
const lookup = {
|
||||||
|
// Main handler called by oninput
|
||||||
|
handle: (value) => {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
selectedIndex = -1; // Reset selection on new input
|
||||||
|
|
||||||
|
if (!value || value.trim().length < config.minLength) {
|
||||||
|
lookup.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup.showSearching();
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
lookup.perform(value);
|
||||||
|
}, config.debounceMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Attach keyboard handler once (for airport lookups)
|
||||||
|
attachKeyboardHandler: () => {
|
||||||
|
if (config.isAirport && !keydownHandlerAttached) {
|
||||||
|
try {
|
||||||
|
const inputField = document.getElementById(fieldId);
|
||||||
|
if (inputField) {
|
||||||
|
inputField.addEventListener('keydown', (e) => lookup.handleKeydown(e));
|
||||||
|
keydownHandlerAttached = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error attaching keyboard handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
handleKeydown: (event) => {
|
||||||
|
if (!currentResults || currentResults.length === 0) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1);
|
||||||
|
lookup.updateSelection();
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||||
|
lookup.updateSelection();
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && currentResults[selectedIndex]) {
|
||||||
|
lookup.selectResult(currentResults[selectedIndex]);
|
||||||
|
} else if (currentResults.length === 1) {
|
||||||
|
// Auto-select if only one result and Enter pressed
|
||||||
|
lookup.selectResult(currentResults[0]);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
lookup.clear();
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update visual selection
|
||||||
|
updateSelection: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (!resultsDiv) return;
|
||||||
|
|
||||||
|
const options = resultsDiv.querySelectorAll('.lookup-option');
|
||||||
|
options.forEach((opt, idx) => {
|
||||||
|
if (idx === selectedIndex) {
|
||||||
|
opt.classList.add('lookup-option-selected');
|
||||||
|
opt.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
opt.classList.remove('lookup-option-selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select a result item
|
||||||
|
selectResult: (item) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = item.icao;
|
||||||
|
}
|
||||||
|
lookup.clear();
|
||||||
|
currentResults = [];
|
||||||
|
selectedIndex = -1;
|
||||||
|
if (selectCallback) selectCallback(item.icao);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Perform the lookup
|
||||||
|
perform: async (searchTerm) => {
|
||||||
|
try {
|
||||||
|
const cleanInput = searchTerm.trim();
|
||||||
|
let endpoint;
|
||||||
|
|
||||||
|
if (config.isAircraft) {
|
||||||
|
const cleaned = cleanInput.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
|
if (cleaned.length < config.minLength) {
|
||||||
|
lookup.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endpoint = `/api/v1/aircraft/lookup/${cleaned}`;
|
||||||
|
} else if (config.isAirport) {
|
||||||
|
endpoint = `/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) throw new Error('Invalid lookup type');
|
||||||
|
|
||||||
|
const response = await authenticatedFetch(endpoint);
|
||||||
|
if (!response.ok) throw new Error('Lookup failed');
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
lookup.display(results, cleanInput);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lookup error:', error);
|
||||||
|
lookup.showError();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
display: (results, searchTerm) => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
|
||||||
|
if (config.isAircraft) {
|
||||||
|
// Aircraft lookup: auto-populate on single match, format input on no match
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
// Format the aircraft registration and auto-populate
|
||||||
|
const formatted = formatAircraftRegistration(searchTerm);
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = formatted;
|
||||||
|
}
|
||||||
|
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
|
||||||
|
} else if (results.length === 1) {
|
||||||
|
// Single match - auto-populate
|
||||||
|
const aircraft = results[0];
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-match">
|
||||||
|
✓ ${aircraft.manufacturer_name || ''} ${aircraft.model || aircraft.type_code || ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auto-populate the form fields
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) field.value = aircraft.registration;
|
||||||
|
|
||||||
|
// Also populate type field
|
||||||
|
let typeFieldId;
|
||||||
|
if (fieldId === 'ac_reg') {
|
||||||
|
typeFieldId = 'ac_type';
|
||||||
|
} else if (fieldId === 'local_registration') {
|
||||||
|
typeFieldId = 'local_type';
|
||||||
|
} else if (fieldId === 'book_in_registration') {
|
||||||
|
typeFieldId = 'book_in_type';
|
||||||
|
} else if (fieldId === 'overflight_registration') {
|
||||||
|
typeFieldId = 'overflight_type';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFieldId) {
|
||||||
|
const typeField = document.getElementById(typeFieldId);
|
||||||
|
if (typeField) typeField.value = aircraft.type_code || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple matches
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-no-match">
|
||||||
|
Multiple matches found (${results.length}) - please be more specific
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Airport lookup: show list of options with keyboard navigation
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-no-match">No matches found - will use as entered</div>';
|
||||||
|
currentResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentResults = results.slice(0, config.maxResults);
|
||||||
|
selectedIndex = -1; // Reset selection when showing new results
|
||||||
|
|
||||||
|
const matchText = currentResults.length === 1 ? 'Match found - press ENTER or click to select:' : 'Multiple matches found - use arrow keys and ENTER to select:';
|
||||||
|
|
||||||
|
let html = `<div class="lookup-no-match" style="margin-bottom: 0.5rem;">${matchText}</div><div class="lookup-list">`;
|
||||||
|
|
||||||
|
currentResults.forEach((item, idx) => {
|
||||||
|
html += `
|
||||||
|
<div class="lookup-option" onclick="lookupManager.selectItem('${resultsId}', '${fieldId}', '${item.icao}')">
|
||||||
|
<div class="lookup-code">${item.icao}</div>
|
||||||
|
<div class="lookup-name">${item.name || '-'}</div>
|
||||||
|
${item.city ? `<div class="lookup-location">${item.city}, ${item.country}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
resultsDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach keyboard handler (only once per lookup instance)
|
||||||
|
lookup.attachKeyboardHandler();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show searching state
|
||||||
|
showSearching: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-searching">Searching...</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
showError: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-no-match">Lookup failed - will use as entered</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear results
|
||||||
|
clear: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set the selected value
|
||||||
|
setValue: (value) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = value;
|
||||||
|
}
|
||||||
|
lookup.clear();
|
||||||
|
if (selectCallback) selectCallback(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global lookup manager for all lookups on the page
|
||||||
|
*/
|
||||||
|
const lookupManager = {
|
||||||
|
lookups: {},
|
||||||
|
|
||||||
|
// Register a lookup instance
|
||||||
|
register: (name, lookup) => {
|
||||||
|
lookupManager.lookups[name] = lookup;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generic item selection handler
|
||||||
|
selectItem: (resultsId, fieldId, itemCode) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = itemCode;
|
||||||
|
}
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize all lookups when page loads
|
||||||
|
function initializeLookups() {
|
||||||
|
// Create reusable lookup instances
|
||||||
|
const arrivalAirportLookup = createLookup(
|
||||||
|
'in_from',
|
||||||
|
'arrival-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('arrival-airport', arrivalAirportLookup);
|
||||||
|
|
||||||
|
const departureAirportLookup = createLookup(
|
||||||
|
'out_to',
|
||||||
|
'departure-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('departure-airport', departureAirportLookup);
|
||||||
|
|
||||||
|
const localOutToLookup = createLookup(
|
||||||
|
'local_out_to',
|
||||||
|
'local-out-to-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('local-out-to', localOutToLookup);
|
||||||
|
|
||||||
|
const aircraftLookup = createLookup(
|
||||||
|
'ac_reg',
|
||||||
|
'aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('aircraft', aircraftLookup);
|
||||||
|
|
||||||
|
const localAircraftLookup = createLookup(
|
||||||
|
'local_registration',
|
||||||
|
'local-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('local-aircraft', localAircraftLookup);
|
||||||
|
|
||||||
|
const bookInAircraftLookup = createLookup(
|
||||||
|
'book_in_registration',
|
||||||
|
'book-in-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('book-in-aircraft', bookInAircraftLookup);
|
||||||
|
|
||||||
|
const bookInArrivalAirportLookup = createLookup(
|
||||||
|
'book_in_from',
|
||||||
|
'book-in-arrival-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('book-in-arrival-airport', bookInArrivalAirportLookup);
|
||||||
|
|
||||||
|
const overflightAircraftLookup = createLookup(
|
||||||
|
'overflight_registration',
|
||||||
|
'overflight-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-aircraft', overflightAircraftLookup);
|
||||||
|
|
||||||
|
const overflightDepartureLookup = createLookup(
|
||||||
|
'overflight_departure_airfield',
|
||||||
|
'overflight-departure-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-departure', overflightDepartureLookup);
|
||||||
|
|
||||||
|
const overflightDestinationLookup = createLookup(
|
||||||
|
'overflight_destination_airfield',
|
||||||
|
'overflight-destination-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-destination', overflightDestinationLookup);
|
||||||
|
|
||||||
|
// Attach keyboard handlers to airport input fields
|
||||||
|
setTimeout(() => {
|
||||||
|
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
|
||||||
|
if (departureAirportLookup.attachKeyboardHandler) departureAirportLookup.attachKeyboardHandler();
|
||||||
|
if (localOutToLookup.attachKeyboardHandler) localOutToLookup.attachKeyboardHandler();
|
||||||
|
if (bookInArrivalAirportLookup.attachKeyboardHandler) bookInArrivalAirportLookup.attachKeyboardHandler();
|
||||||
|
if (overflightDepartureLookup.attachKeyboardHandler) overflightDepartureLookup.attachKeyboardHandler();
|
||||||
|
if (overflightDestinationLookup.attachKeyboardHandler) overflightDestinationLookup.attachKeyboardHandler();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready or immediately if already loaded
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeLookups);
|
||||||
|
} else {
|
||||||
|
initializeLookups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper functions for backward compatibility
|
||||||
|
*/
|
||||||
|
function handleArrivalAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['arrival-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDepartureAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['departure-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalOutToAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['local-out-to'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['local-aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearArrivalAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['arrival-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDepartureAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['departure-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalOutToAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['local-out-to'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['local-aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectArrivalAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('arrival-airport-lookup-results', 'in_from', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDepartureAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('departure-airport-lookup-results', 'out_to', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocalOutToAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('local-out-to-lookup-results', 'local_out_to', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocalAircraft(registration) {
|
||||||
|
lookupManager.selectItem('local-aircraft-lookup-results', 'local_registration', registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookInAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['book-in-aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookInArrivalAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['book-in-arrival-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookInAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['book-in-aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookInArrivalAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['book-in-arrival-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBookInAircraft(registration) {
|
||||||
|
lookupManager.selectItem('book-in-aircraft-lookup-results', 'book_in_registration', registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBookInArrivalAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
|
||||||
|
}
|
||||||
893
web/ppr.html
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Swansea PPR</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required::after {
|
||||||
|
content: " *";
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #155724;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-match {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
border: 1px solid #c3e6c3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-details {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr; /* Single column on mobile */
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
padding: 0.7rem;
|
||||||
|
font-size: 1rem; /* Prevent zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-lookup-results, .aircraft-lookup-results {
|
||||||
|
max-height: 200px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-option, .airport-option {
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>✈️ PPR Request</h1>
|
||||||
|
<p>Please fill out the form below to submit a PPR request for Swansea Airport.</p>
|
||||||
|
<p>Note that this is a new form, and is under test. Please email james.pattinson@sasalliance.org if you have any issues with the form.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ppr-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ac_reg" class="required">Aircraft Registration</label>
|
||||||
|
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
|
||||||
|
<div id="aircraft-lookup-results" class="aircraft-lookup-results"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ac_type" class="required">Aircraft Type</label>
|
||||||
|
<input type="text" id="ac_type" name="ac_type" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ac_call">Callsign</label>
|
||||||
|
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="captain" class="required">Captain/Pilot Name</label>
|
||||||
|
<input type="text" id="captain" name="captain" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="in_from" class="required">Arriving From</label>
|
||||||
|
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
|
||||||
|
<div id="arrival-airport-lookup-results" class="airport-lookup-results"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eta" class="required">Estimated Time of Arrival (Local Time)</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||||
|
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||||
|
<option value="">Select Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pob_in" class="required">Persons on Board (Arrival)</label>
|
||||||
|
<input type="number" id="pob_in" name="pob_in" required min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fuel">Fuel Required</label>
|
||||||
|
<select id="fuel" name="fuel">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="100LL">100LL</option>
|
||||||
|
<option value="JET A1">JET A1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="out_to">Departing To</label>
|
||||||
|
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
|
||||||
|
<div id="departure-airport-lookup-results" class="airport-lookup-results"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="etd">Estimated Time of Departure (Local Time)</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="date" id="etd-date" name="etd-date" style="flex: 1;">
|
||||||
|
<select id="etd-time" name="etd-time" style="flex: 1;">
|
||||||
|
<option value="">Select Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pob_out">Persons on Board (Departure)</label>
|
||||||
|
<input type="number" id="pob_out" name="pob_out" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone">Phone Number</label>
|
||||||
|
<input type="tel" id="phone" name="phone">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="notes">Additional Notes</label>
|
||||||
|
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||||
|
Submit PPR Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Submitting your PPR request...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="success-message" id="success-message">
|
||||||
|
<h3>PPR Request Submitted.</h3>
|
||||||
|
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
|
||||||
|
<p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Notification -->
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script>
|
||||||
|
// API base URL for iframe embedding - loaded from config.js or fallback
|
||||||
|
console.log('=== PPR Config Debug ===');
|
||||||
|
console.log('window.PPR_CONFIG:', window.PPR_CONFIG);
|
||||||
|
console.log('window.PPR_CONFIG.apiBase:', window.PPR_CONFIG?.apiBase);
|
||||||
|
|
||||||
|
const API_BASE = (window.PPR_CONFIG && window.PPR_CONFIG.apiBase) || '/api/v1';
|
||||||
|
|
||||||
|
console.log('Final API_BASE:', API_BASE);
|
||||||
|
console.log('Source:', window.PPR_CONFIG?.apiBase ? 'config.js' : 'fallback');
|
||||||
|
console.log('=======================');
|
||||||
|
|
||||||
|
// Track if user has manually edited ETD
|
||||||
|
let etdManuallyEdited = false;
|
||||||
|
|
||||||
|
// Function to update ETD based on ETA (2 hours later)
|
||||||
|
function updateETDFromETA() {
|
||||||
|
// Only auto-update if user hasn't manually edited ETD
|
||||||
|
if (etdManuallyEdited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etaDate = document.getElementById('eta-date').value;
|
||||||
|
const etaTime = document.getElementById('eta-time').value;
|
||||||
|
|
||||||
|
if (etaDate && etaTime) {
|
||||||
|
// Parse ETA
|
||||||
|
const eta = new Date(`${etaDate}T${etaTime}`);
|
||||||
|
|
||||||
|
// Calculate ETD (2 hours after ETA)
|
||||||
|
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Format ETD
|
||||||
|
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
|
||||||
|
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Update ETD fields
|
||||||
|
document.getElementById('etd-date').value = etdDateStr;
|
||||||
|
document.getElementById('etd-time').value = etdTimeStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to mark ETD as manually edited
|
||||||
|
function markETDAsManuallyEdited() {
|
||||||
|
etdManuallyEdited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iframe resizing functionality
|
||||||
|
function sendHeightToParent() {
|
||||||
|
const height = document.body.scrollHeight || document.documentElement.scrollHeight;
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'setHeight',
|
||||||
|
height: height + 20 // Add some padding
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send height on load and resize
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
sendHeightToParent();
|
||||||
|
// Also send height after any content changes
|
||||||
|
setTimeout(sendHeightToParent, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', sendHeightToParent);
|
||||||
|
|
||||||
|
// Initialize time dropdowns
|
||||||
|
function initializeTimeDropdowns() {
|
||||||
|
const timeSelects = ['eta-time', 'etd-time'];
|
||||||
|
|
||||||
|
timeSelects.forEach(selectId => {
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
// Clear existing options except the first one
|
||||||
|
select.innerHTML = '<option value="">Select Time</option>';
|
||||||
|
|
||||||
|
// Add time options in 15-minute intervals
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
for (let minute = 0; minute < 60; minute += 15) {
|
||||||
|
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = timeString;
|
||||||
|
option.textContent = timeString;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification system
|
||||||
|
function showNotification(message, isError = false) {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.className = 'notification' + (isError ? ' error' : '');
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('show');
|
||||||
|
// Update iframe height when notification appears
|
||||||
|
setTimeout(sendHeightToParent, 50);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Hide after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
// Update iframe height when notification disappears
|
||||||
|
setTimeout(sendHeightToParent, 50);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aircraft lookup
|
||||||
|
let aircraftLookupTimeout;
|
||||||
|
async function handleAircraftLookup(registration) {
|
||||||
|
clearTimeout(aircraftLookupTimeout);
|
||||||
|
const resultsDiv = document.getElementById('aircraft-lookup-results');
|
||||||
|
const regField = document.getElementById('ac_reg');
|
||||||
|
|
||||||
|
if (!registration || registration.length < 4) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||||
|
|
||||||
|
aircraftLookupTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
if (data.length === 1) {
|
||||||
|
// Single match - auto-populate the registration field
|
||||||
|
const aircraft = data[0];
|
||||||
|
regField.value = aircraft.registration || registration.toUpperCase();
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="lookup-match">
|
||||||
|
${aircraft.registration || registration.toUpperCase()} - ${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Auto-fill aircraft type if not already filled
|
||||||
|
if (!document.getElementById('ac_type').value) {
|
||||||
|
document.getElementById('ac_type').value = aircraft.type_code || '';
|
||||||
|
}
|
||||||
|
} else if (data.length <= 10) {
|
||||||
|
// Multiple matches - show as clickable list
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-list">
|
||||||
|
${data.map(aircraft => `
|
||||||
|
<div class="aircraft-option" onclick="selectAircraft('${aircraft.registration || registration.toUpperCase()}', '${aircraft.type_code || ''}')">
|
||||||
|
<div class="aircraft-code">${aircraft.registration || registration.toUpperCase()}</div>
|
||||||
|
<div class="aircraft-details">${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Too many matches
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">No aircraft found with this registration</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Aircraft lookup error:', error);
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAircraft(registration, typeCode) {
|
||||||
|
document.getElementById('ac_reg').value = registration;
|
||||||
|
// Always update the aircraft type when a specific aircraft is selected
|
||||||
|
document.getElementById('ac_type').value = typeCode;
|
||||||
|
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||||
|
// Remove focus from the field to hide the dropdown
|
||||||
|
document.getElementById('ac_reg').blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear aircraft lookup results when input loses focus
|
||||||
|
document.getElementById('ac_reg').addEventListener('blur', function() {
|
||||||
|
// Delay clearing to allow click events on options
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Airport lookup functions
|
||||||
|
let arrivalAirportLookupTimeout;
|
||||||
|
async function handleArrivalAirportLookup(query) {
|
||||||
|
clearTimeout(arrivalAirportLookupTimeout);
|
||||||
|
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
|
||||||
|
const inputField = document.getElementById('in_from');
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||||
|
|
||||||
|
arrivalAirportLookupTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
if (data.length === 1) {
|
||||||
|
// Single match - show as clickable option
|
||||||
|
const airport = data[0];
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-list">
|
||||||
|
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
|
||||||
|
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||||
|
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (data.length <= 10) {
|
||||||
|
// Multiple matches - show as clickable list
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-list">
|
||||||
|
${data.map(airport => `
|
||||||
|
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
|
||||||
|
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||||
|
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Too many matches
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Arrival airport lookup error:', error);
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
let departureAirportLookupTimeout;
|
||||||
|
async function handleDepartureAirportLookup(query) {
|
||||||
|
clearTimeout(departureAirportLookupTimeout);
|
||||||
|
const resultsDiv = document.getElementById('departure-airport-lookup-results');
|
||||||
|
const inputField = document.getElementById('out_to');
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||||
|
|
||||||
|
departureAirportLookupTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
if (data.length === 1) {
|
||||||
|
// Single match - show as clickable option
|
||||||
|
const airport = data[0];
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-list">
|
||||||
|
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
|
||||||
|
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||||
|
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (data.length <= 10) {
|
||||||
|
// Multiple matches - show as clickable list
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-list">
|
||||||
|
${data.map(airport => `
|
||||||
|
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
|
||||||
|
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||||
|
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Too many matches
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Departure airport lookup error:', error);
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAirport(fieldId, icaoCode) {
|
||||||
|
document.getElementById(fieldId).value = icaoCode;
|
||||||
|
// Clear the results
|
||||||
|
const resultsDivId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
|
||||||
|
document.getElementById(resultsDivId).innerHTML = '';
|
||||||
|
// Remove focus from the field to hide the dropdown
|
||||||
|
document.getElementById(fieldId).blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear airport lookup results when input loses focus
|
||||||
|
document.getElementById('in_from').addEventListener('blur', function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('out_to').addEventListener('blur', function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('departure-airport-lookup-results').innerHTML = '';
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const pprData = {};
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (value.trim() !== '') {
|
||||||
|
if (key === 'pob_in' || key === 'pob_out') {
|
||||||
|
pprData[key] = parseInt(value);
|
||||||
|
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||||
|
// Combine date and time for ETA
|
||||||
|
const dateStr = formData.get('eta-date');
|
||||||
|
const timeStr = formData.get('eta-time');
|
||||||
|
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||||
|
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||||
|
// Combine date and time for ETD
|
||||||
|
const dateStr = formData.get('etd-date');
|
||||||
|
const timeStr = formData.get('etd-time');
|
||||||
|
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||||
|
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||||
|
// Skip the time fields as they're handled above
|
||||||
|
pprData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
document.getElementById('submit-btn').disabled = true;
|
||||||
|
document.getElementById('submit-btn').textContent = 'Submitting...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/pprs/public`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(pprData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('PPR submitted successfully:', result);
|
||||||
|
|
||||||
|
// Hide form and show success message
|
||||||
|
document.getElementById('ppr-form').style.display = 'none';
|
||||||
|
document.getElementById('success-message').style.display = 'block';
|
||||||
|
|
||||||
|
showNotification('PPR request submitted successfully!');
|
||||||
|
// Update iframe height after content change
|
||||||
|
setTimeout(sendHeightToParent, 100);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting PPR:', error);
|
||||||
|
showNotification(`Error submitting PPR: ${error.message}`, true);
|
||||||
|
// Update iframe height after showing error
|
||||||
|
setTimeout(sendHeightToParent, 100);
|
||||||
|
} finally {
|
||||||
|
// Hide loading
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('submit-btn').disabled = false;
|
||||||
|
document.getElementById('submit-btn').textContent = 'Submit PPR Request';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default date and time values
|
||||||
|
function setDefaultDateTime() {
|
||||||
|
const now = new Date();
|
||||||
|
// Round up to the next hour
|
||||||
|
const nextHour = new Date(now);
|
||||||
|
nextHour.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
|
|
||||||
|
// ETD is 2 hours after ETA
|
||||||
|
const etd = new Date(nextHour);
|
||||||
|
etd.setHours(nextHour.getHours() + 2);
|
||||||
|
|
||||||
|
// Format date and time for separate inputs
|
||||||
|
function formatDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date) {
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ETA to next hour
|
||||||
|
document.getElementById('eta-date').value = formatDate(nextHour);
|
||||||
|
document.getElementById('eta-time').value = formatTime(nextHour);
|
||||||
|
|
||||||
|
// Set ETD to one hour after ETA
|
||||||
|
document.getElementById('etd-date').value = formatDate(etd);
|
||||||
|
document.getElementById('etd-time').value = formatTime(etd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the page when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initializeTimeDropdowns();
|
||||||
|
setDefaultDateTime();
|
||||||
|
|
||||||
|
// Add event listeners to ETA fields to auto-update ETD
|
||||||
|
document.getElementById('eta-date').addEventListener('change', updateETDFromETA);
|
||||||
|
document.getElementById('eta-time').addEventListener('change', updateETDFromETA);
|
||||||
|
|
||||||
|
// Add event listeners to ETD fields to mark as manually edited
|
||||||
|
document.getElementById('etd-date').addEventListener('change', markETDAsManuallyEdited);
|
||||||
|
document.getElementById('etd-time').addEventListener('change', markETDAsManuallyEdited);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1386
web/reports.html
Normal file
1
web/widgets/iframe.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
function initEmbed(e){window.addEventListener("message",function(t){if(t.data.type==="setHeight"){var n=document.getElementById(e);n&&(n.style.height=t.data.height+"px")}})}
|
||||||