Compare commits
57 Commits
77b5080bbd
...
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 |
23
.env.example
@@ -1,8 +1,8 @@
|
||||
# Database Configuration
|
||||
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here
|
||||
DB_USER=ppr_user
|
||||
DB_USER=your_database_user_here
|
||||
DB_PASSWORD=your_database_password_here
|
||||
DB_NAME=ppr
|
||||
DB_NAME=your_database_name_here
|
||||
DB_PORT=3306
|
||||
|
||||
# API Configuration
|
||||
@@ -13,6 +13,25 @@ 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
|
||||
|
||||
|
||||
7
.gitignore
vendored
@@ -30,6 +30,10 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Alembic - keep migration files but not temporary files
|
||||
# backend/alembic/versions/*.pyc
|
||||
# backend/alembic/__pycache__/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -68,6 +72,9 @@ logs/
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Generated config files
|
||||
web/config.js
|
||||
|
||||
# Coverage reports
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
65
README.md
@@ -28,24 +28,72 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
|
||||
### 1. Start the System
|
||||
### Development Setup
|
||||
|
||||
**Start the system (automatic database setup):**
|
||||
```bash
|
||||
cd nextgen
|
||||
./start.sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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
|
||||
- **API Documentation**: http://localhost:8001/docs
|
||||
- **API Base URL**: http://localhost:8001/api/v1
|
||||
- **Public Web Interface**: http://localhost:8082
|
||||
- **Admin Interface**: http://localhost:8082/admin.html
|
||||
- **Reports Interface**: http://localhost:8082/reports.html
|
||||
- **Database**: localhost:3307 (user: ppr_user, password: ppr_password123)
|
||||
- **Database**: localhost:3307 (user: ppr_user, password: [configured in .env])
|
||||
- **phpMyAdmin**: http://localhost:8083
|
||||
|
||||
### 3. Default Login
|
||||
- **Username**: admin
|
||||
- **Password**: admin123
|
||||
- **Password**: [configured in database - see init_db.sql]
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -129,12 +177,7 @@ uvicorn app.main:app --reload
|
||||
# Connect to database
|
||||
docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f api
|
||||
docker-compose logs -f db
|
||||
|
||||
# Restart services
|
||||
docker-compose restart
|
||||
# When prompted for password, use the value from .env (DB_PASSWORD)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -19,8 +19,12 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x /app/entrypoint.sh && \
|
||||
chmod +x /app/seed_data.py
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Use entrypoint script
|
||||
ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
|
||||
@@ -1,138 +0,0 @@
|
||||
# Test Data Population Script
|
||||
|
||||
This script generates and inserts 30 random PPR (Prior Permission Required) records into the database for testing purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- **30 Random PPR Records**: Generates diverse test data with various aircraft, airports, and flight details
|
||||
- **Real Aircraft Data**: Uses actual aircraft registration data from the `aircraft_data.csv` file
|
||||
- **Real Airport Data**: Uses actual airport ICAO codes from the `airports_data_clean.csv` file
|
||||
- **Random Status Distribution**: Includes NEW, CONFIRMED, LANDED, and DEPARTED statuses
|
||||
- **Realistic Timestamps**: Generates ETA/ETD times with 15-minute intervals
|
||||
- **Optional Fields**: Randomly includes email, phone, notes, and departure details
|
||||
- **Duplicate Aircraft**: Some aircraft registrations appear multiple times for realistic testing
|
||||
|
||||
## Usage
|
||||
|
||||
### Prerequisites
|
||||
- Database must be running and accessible
|
||||
- Python environment with required dependencies installed
|
||||
- CSV data files (`aircraft_data.csv` and `airports_data_clean.csv`) in the parent directory
|
||||
|
||||
### Running the Script
|
||||
|
||||
1. **Using the convenience script** (recommended):
|
||||
```bash
|
||||
cd /home/jamesp/docker/pprdev/nextgen
|
||||
./populate_test_data.sh
|
||||
```
|
||||
|
||||
2. **From within the Docker container**:
|
||||
```bash
|
||||
docker exec -it ppr-backend bash
|
||||
cd /app
|
||||
python populate_test_data.py
|
||||
```
|
||||
|
||||
3. **From host machine** (if database is accessible):
|
||||
```bash
|
||||
cd /home/jamesp/docker/pprdev/nextgen/backend
|
||||
python populate_test_data.py
|
||||
```
|
||||
|
||||
## What Gets Generated
|
||||
|
||||
Each PPR record includes:
|
||||
- **Aircraft**: Random registration, type, and callsign from real aircraft data
|
||||
- **Route**: Random arrival airport (from Swansea), optional departure airport
|
||||
- **Times**: ETA between 6 AM - 8 PM, ETD 1-4 hours later (if departing)
|
||||
- **Passengers**: 1-4 POB for arrival, optional for departure
|
||||
- **Contact**: Optional email and phone (70% and 50% chance respectively)
|
||||
- **Fuel**: Random fuel type (100LL, JET A1, FULL) or none
|
||||
- **Notes**: Optional flight purpose notes (various scenarios)
|
||||
- **Status**: Random status distribution (NEW/CONFIRMED/LANDED/DEPARTED)
|
||||
- **Timestamps**: Random submission dates within last 30 days
|
||||
- **Public Token**: Auto-generated for edit/cancel functionality
|
||||
|
||||
### Aircraft Distribution
|
||||
- Uses real aircraft registration data from `aircraft_data.csv`
|
||||
- Includes various aircraft types (C172, PA28, BE36, R44, etc.)
|
||||
- Some aircraft appear multiple times for realistic duplication
|
||||
|
||||
### Airport Distribution
|
||||
- Uses real ICAO airport codes from `airports_data_clean.csv`
|
||||
- Arrival airports are distributed globally
|
||||
- Departure airports (when included) are different from arrival airports
|
||||
|
||||
### Data Quality Notes
|
||||
|
||||
- **Realistic Distribution**: Aircraft and airports are selected from actual aviation data
|
||||
- **Time Constraints**: All times are within reasonable operating hours (6 AM - 8 PM)
|
||||
- **Status Balance**: Roughly equal distribution across different PPR statuses
|
||||
- **Contact Info**: Realistic email patterns and UK phone numbers
|
||||
- **Flight Logic**: Departures only occur when a departure airport is specified
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Database schema matches the PPRRecord model in `app/models/ppr.py`
|
||||
- CSV files are present and properly formatted
|
||||
- Database connection uses settings from `app/core/config.py`
|
||||
- All required dependencies are installed in the Python environment
|
||||
|
||||
### Sample Output
|
||||
|
||||
```
|
||||
Loading aircraft and airport data...
|
||||
Loaded 520000 aircraft records
|
||||
Loaded 43209 airport records
|
||||
Generating and inserting 30 test PPR records...
|
||||
Generated 10 records...
|
||||
Generated 20 records...
|
||||
Generated 30 records...
|
||||
✅ Successfully inserted 30 test PPR records!
|
||||
Total PPR records in database: 42
|
||||
|
||||
Status breakdown:
|
||||
NEW: 8
|
||||
CONFIRMED: 7
|
||||
LANDED: 9
|
||||
DEPARTED: 6
|
||||
```
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- **Non-destructive**: Only adds new records, doesn't modify existing data
|
||||
- **Test Data Only**: All generated data is clearly identifiable as test data
|
||||
- **Easy Cleanup**: Can be easily removed with SQL queries if needed
|
||||
|
||||
## Current Status ✅
|
||||
|
||||
The script is working correctly! It has successfully generated and inserted test data. As of the latest run:
|
||||
|
||||
- **Total PPR records in database**: 93
|
||||
- **Status breakdown**:
|
||||
- NEW: 19
|
||||
- CONFIRMED: 22
|
||||
- CANCELED: 1
|
||||
- LANDED: 35
|
||||
- DEPARTED: 16
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Database Connection**: Ensure the database container is running and accessible
|
||||
- **CSV Files**: The script uses fallback data when CSV files aren't found (which is normal in containerized environments)
|
||||
- **Dependencies**: Ensure all Python requirements are installed
|
||||
- **Permissions**: Script needs database write permissions
|
||||
|
||||
## Recent Fixes
|
||||
|
||||
- ✅ Fixed SQLAlchemy 2.0 `func.count()` import issue
|
||||
- ✅ Script now runs successfully and provides status breakdown
|
||||
- ✅ Uses fallback aircraft/airport data when CSV files aren't accessible
|
||||
|
||||
## Cleanup (if needed)
|
||||
|
||||
To remove all test data:
|
||||
```sql
|
||||
DELETE FROM submitted WHERE submitted_dt > '2025-01-01'; -- Adjust date as needed
|
||||
```
|
||||
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 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.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||
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(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
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.security import create_access_token
|
||||
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()
|
||||
|
||||
@@ -33,7 +33,11 @@ async def login_for_access_token(
|
||||
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)
|
||||
@@ -54,6 +58,22 @@ async def list_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)
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
@@ -86,4 +106,22 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/change-password", response_model=User)
|
||||
async def change_user_password(
|
||||
user_id: int,
|
||||
password_data: ChangePassword,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Change a user's password (admin only)"""
|
||||
user = crud_user.get(db, user_id=user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
|
||||
return user
|
||||
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
|
||||
@@ -3,20 +3,240 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db
|
||||
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.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)):
|
||||
"""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)
|
||||
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)):
|
||||
"""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)
|
||||
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):
|
||||
# Database settings
|
||||
db_host: str = "db" # Docker service name
|
||||
db_user: str = "ppr_user"
|
||||
db_password: str = "ppr_password123"
|
||||
db_name: str = "ppr_nextgen"
|
||||
db_user: str
|
||||
db_password: str
|
||||
db_name: str
|
||||
db_port: int = 3306
|
||||
|
||||
# Security settings
|
||||
secret_key: str = "your-secret-key-change-this-in-production"
|
||||
secret_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# Mail settings
|
||||
mail_host: str = "send.one.com"
|
||||
mail_host: str
|
||||
mail_port: int = 465
|
||||
mail_username: str = "noreply@swansea-airport.wales"
|
||||
mail_password: str = "SASAGoForward2155"
|
||||
mail_from: str = "noreply@swansea-airport.wales"
|
||||
mail_from_name: str = "Swansea Airport"
|
||||
mail_username: str
|
||||
mail_password: str
|
||||
mail_from: str
|
||||
mail_from_name: str
|
||||
|
||||
# Application settings
|
||||
api_v1_str: str = "/api/v1"
|
||||
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_url: Optional[str] = None
|
||||
|
||||
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 app.models.ppr import Journal
|
||||
from app.schemas.ppr import JournalCreate
|
||||
from app.models.journal import JournalEntry, EntityType
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CRUDJournal:
|
||||
def create(self, db: Session, obj_in: JournalCreate) -> Journal:
|
||||
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]:
|
||||
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
|
||||
|
||||
"""CRUD operations for the generic journal table.
|
||||
|
||||
This journal is immutable - entries can only be created (by backend) and queried.
|
||||
There are no API endpoints for creating journal entries; the backend logs changes directly.
|
||||
"""
|
||||
|
||||
def log_change(
|
||||
self,
|
||||
db: Session,
|
||||
ppr_id: int,
|
||||
entity_type: EntityType,
|
||||
entity_id: int,
|
||||
entry: str,
|
||||
user: str,
|
||||
ip: str
|
||||
) -> Journal:
|
||||
journal_in = JournalCreate(
|
||||
ppr_id=ppr_id,
|
||||
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,
|
||||
db: Session,
|
||||
ppr_id: int,
|
||||
entry: str,
|
||||
user: str,
|
||||
ip: Optional[str] = None
|
||||
) -> JournalEntry:
|
||||
"""Log a change to a PPR (convenience method)."""
|
||||
return self.log_change(
|
||||
db=db,
|
||||
entity_type=EntityType.PPR,
|
||||
entity_id=ppr_id,
|
||||
entry=entry,
|
||||
user=user,
|
||||
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()
|
||||
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()
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
from app.models.journal import EntityType
|
||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
|
||||
@@ -48,12 +49,17 @@ class CRUDPPR:
|
||||
return query.order_by(desc(PPRRecord.submitted_dt)).offset(skip).limit(limit).all()
|
||||
|
||||
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()
|
||||
return db.query(PPRRecord).filter(
|
||||
and_(
|
||||
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()
|
||||
|
||||
@@ -63,7 +69,10 @@ class CRUDPPR:
|
||||
return db.query(PPRRecord).filter(
|
||||
and_(
|
||||
func.date(PPRRecord.etd) == today,
|
||||
PPRRecord.status == PPRStatus.LANDED
|
||||
or_(
|
||||
PPRRecord.status == PPRStatus.LANDED,
|
||||
PPRRecord.status == PPRStatus.DEPARTED
|
||||
)
|
||||
)
|
||||
).order_by(PPRRecord.etd).all()
|
||||
|
||||
@@ -81,6 +90,7 @@ class CRUDPPR:
|
||||
# Log creation in journal
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
db_obj.id,
|
||||
f"PPR created for {db_obj.ac_reg}",
|
||||
created_by,
|
||||
@@ -90,11 +100,22 @@ class CRUDPPR:
|
||||
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:
|
||||
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)
|
||||
@@ -106,7 +127,7 @@ class CRUDPPR:
|
||||
|
||||
# Log changes in journal
|
||||
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
|
||||
|
||||
@@ -138,7 +159,7 @@ class CRUDPPR:
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log status change in journal
|
||||
crud_journal.log_change(
|
||||
crud_journal.log_ppr_change(
|
||||
db,
|
||||
db_obj.id,
|
||||
f"Status changed from {old_status.value} to {status.value}",
|
||||
|
||||
@@ -50,5 +50,14 @@ class CRUDUser:
|
||||
# For future use if we add user status
|
||||
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()
|
||||
@@ -2,9 +2,27 @@ from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconn
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import redis.asyncio as redis
|
||||
from app.core.config import settings
|
||||
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(
|
||||
title=settings.project_name,
|
||||
openapi_url=f"{settings.api_v1_str}/openapi.json",
|
||||
@@ -25,28 +43,117 @@ app.add_middleware(
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
self.redis_listener_task = None
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
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):
|
||||
await websocket.send_text(message)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
message_str = json.dumps(message)
|
||||
async def broadcast_local(self, message_str: str):
|
||||
"""Broadcast to connections on this worker only"""
|
||||
dead_connections = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_text(message_str)
|
||||
except:
|
||||
# Connection is dead, remove it
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
@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")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
@@ -69,7 +176,29 @@ async def root():
|
||||
|
||||
@app.get("/health")
|
||||
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
|
||||
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,26 +23,27 @@ class PPRRecord(Base):
|
||||
__tablename__ = "submitted"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW)
|
||||
ac_reg = Column(String(16), nullable=False)
|
||||
status = Column(SQLEnum(PPRStatus), nullable=False, default=PPRStatus.NEW, index=True)
|
||||
ac_reg = Column(String(16), nullable=False, index=True)
|
||||
ac_type = Column(String(32), nullable=False)
|
||||
ac_call = Column(String(16), nullable=True)
|
||||
captain = Column(String(64), nullable=False)
|
||||
fuel = Column(String(16), nullable=True)
|
||||
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)
|
||||
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)
|
||||
email = Column(String(128), 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)
|
||||
departed_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True)
|
||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
public_token = Column(String(128), nullable=True, unique=True)
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
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):
|
||||
@@ -52,17 +53,11 @@ class User(Base):
|
||||
username = Column(String(50), nullable=False, unique=True, index=True)
|
||||
password = Column(String(255), nullable=False)
|
||||
role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY)
|
||||
|
||||
|
||||
class Journal(Base):
|
||||
__tablename__ = "journal"
|
||||
|
||||
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())
|
||||
email = Column(String(128), nullable=True)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
is_active = Column(Integer, nullable=False, default=1) # Using Integer for BOOLEAN
|
||||
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())
|
||||
|
||||
|
||||
class Airport(Base):
|
||||
@@ -85,12 +80,12 @@ class Aircraft(Base):
|
||||
__tablename__ = "aircraft"
|
||||
|
||||
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)
|
||||
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)
|
||||
model = Column(String(255), nullable=True)
|
||||
clean_reg = Column(String(25), nullable=True, index=True)
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
@@ -116,6 +135,11 @@ class UserUpdate(BaseModel):
|
||||
role: Optional[UserRole] = None
|
||||
|
||||
|
||||
class ChangePassword(BaseModel):
|
||||
"""Schema for admin-initiated password changes"""
|
||||
password: str
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
id: int
|
||||
|
||||
@@ -135,6 +159,7 @@ class UserInDB(UserInDBBase):
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int # Token expiry in seconds
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
|
||||
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
|
||||
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())
|
||||
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
|
||||
@@ -30,16 +30,46 @@ services:
|
||||
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}
|
||||
TOWER_NAME: ${TOWER_NAME}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
ENVIRONMENT: ${ENVIRONMENT}
|
||||
ports:
|
||||
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||
networks:
|
||||
- private_network
|
||||
- public_network
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# 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:
|
||||
@@ -49,21 +79,6 @@ services:
|
||||
networks:
|
||||
- private_network
|
||||
|
||||
# Nginx web server for public frontend
|
||||
web:
|
||||
image: nginx:alpine
|
||||
container_name: ppr_nextgen_web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT_EXTERNAL}:80" # Public web interface
|
||||
volumes:
|
||||
- ./web:/usr/share/nginx/html
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- public_network
|
||||
|
||||
# phpMyAdmin for database management
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
|
||||
29
nginx.conf
@@ -29,9 +29,24 @@ http {
|
||||
root /usr/share/nginx/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
|
||||
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
|
||||
@@ -53,13 +68,21 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
4448
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 |
1099
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);
|
||||
}
|
||||
237
web/ppr.html
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swansea PPR Request</title>
|
||||
<title>Swansea PPR</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -254,13 +254,91 @@
|
||||
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>✈️ Swansea Airport PPR Request</h1>
|
||||
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</p>
|
||||
<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">
|
||||
@@ -306,7 +384,6 @@
|
||||
<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">
|
||||
@@ -354,16 +431,83 @@
|
||||
</div>
|
||||
|
||||
<div class="success-message" id="success-message">
|
||||
<h3>✅ PPR Request Submitted Successfully!</h3>
|
||||
<p>Your Prior Permission Required request has been submitted and will be reviewed by airport operations. You will receive confirmation via email if provided.</p>
|
||||
<p><strong>Please note:</strong> This is not confirmation of approval. Airport operations will contact you if additional information is required.</p>
|
||||
<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'];
|
||||
@@ -395,11 +539,15 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -419,7 +567,7 @@
|
||||
|
||||
aircraftLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
@@ -498,17 +646,19 @@
|
||||
|
||||
arrivalAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
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 - auto-populate the input field with ICAO code
|
||||
// Single match - show as clickable option
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
<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) {
|
||||
@@ -555,17 +705,19 @@
|
||||
|
||||
departureAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
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 - auto-populate the input field with ICAO code
|
||||
// Single match - show as clickable option
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
<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) {
|
||||
@@ -653,7 +805,7 @@
|
||||
document.getElementById('submit-btn').textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/pprs/public', {
|
||||
const response = await fetch(`${API_BASE}/pprs/public`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -670,6 +822,8 @@
|
||||
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}`);
|
||||
@@ -677,6 +831,8 @@
|
||||
} 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';
|
||||
@@ -685,9 +841,52 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
738
web/reports.html
@@ -22,27 +22,37 @@
|
||||
color: white;
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.top-bar .user-info {
|
||||
order: 3;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -226,6 +236,45 @@
|
||||
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
||||
|
||||
.summary-box {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.summary-item-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-item-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
@@ -312,14 +361,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<div class="title">
|
||||
<h1>📊 PPR Reports</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<button class="btn btn-secondary" onclick="window.location.href='admin.html'">
|
||||
<button class="btn btn-secondary" onclick="window.location.href='admin'">
|
||||
← Back to Admin
|
||||
</button>
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1 id="tower-title">📊 PPR Reports</h1>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
@@ -329,18 +378,26 @@
|
||||
<div class="container">
|
||||
<!-- Filters Section -->
|
||||
<div class="filters-section">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-group">
|
||||
<label for="date-from">Date From:</label>
|
||||
<input type="date" id="date-from">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
|
||||
<!-- Quick Filter Buttons -->
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" id="filter-today" onclick="setDateRangeToday()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📅 Today</button>
|
||||
<button class="btn btn-secondary" id="filter-week" onclick="setDateRangeThisWeek()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📆 This Week</button>
|
||||
<button class="btn btn-secondary" id="filter-month" onclick="setDateRangeThisMonth()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📊 This Month</button>
|
||||
<button class="btn btn-secondary" id="filter-custom" onclick="toggleCustomRange()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📋 Custom Range</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="date-to">Date To:</label>
|
||||
<input type="date" id="date-to">
|
||||
|
||||
<!-- Custom Date Range (hidden by default) -->
|
||||
<div id="custom-range-container" style="display: none; display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="date" id="date-from" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<span style="font-weight: 600; color: #666;">to</span>
|
||||
<input type="date" id="date-to" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Status:</label>
|
||||
<select id="status-filter">
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
|
||||
<label for="status-filter" style="font-weight: 600; font-size: 0.85rem; color: #555;">Status:</label>
|
||||
<select id="status-filter" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="NEW">New</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
@@ -350,23 +407,91 @@
|
||||
<option value="DELETED">Deleted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="search-input">Search:</label>
|
||||
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
|
||||
|
||||
<!-- Search Input -->
|
||||
<div style="flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 0.3rem;">
|
||||
<label for="search-input" style="font-weight: 600; font-size: 0.85rem; color: #555;">Search:</label>
|
||||
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..." style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-primary" onclick="loadReports()">
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" onclick="loadReports()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
|
||||
🔍 Search
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">
|
||||
<button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Box -->
|
||||
<div class="summary-box">
|
||||
<div class="summary-title">📊 Movements Summary</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center;">
|
||||
<div class="summary-grid">
|
||||
<!-- PPR Section -->
|
||||
<div style="grid-column: 1/-1; padding-bottom: 0.8rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
|
||||
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">PPR Movements</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem;">
|
||||
<div class="summary-item" style="padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-arrivals">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-departures">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-total">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Non-PPR Section -->
|
||||
<div style="grid-column: 1/-1; padding-top: 0.8rem;">
|
||||
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">Non-PPR Movements</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.8rem;">
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('LOCAL')">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Local</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="local-flights-movements">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('CIRCUIT')">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Circuits</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="circuits-movements">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('ARRIVAL')">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-arrivals">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('DEPARTURE')">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-total">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grand Total - positioned on the right -->
|
||||
<div style="text-align: center; padding: 1rem; background: rgba(255,215,0,0.05); border-radius: 8px; border-left: 4px solid #ffd700; min-width: 120px;">
|
||||
<div style="font-size: 0.75rem; opacity: 0.85; margin-bottom: 0.2rem;">GRAND TOTAL</div>
|
||||
<div style="font-size: 2.2rem; font-weight: 700;" id="grand-total-movements">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Table -->
|
||||
<div class="reports-table">
|
||||
<div class="reports-table" id="ppr-reports-section">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<strong>PPR Records</strong>
|
||||
@@ -376,9 +501,6 @@
|
||||
<button class="btn btn-success" onclick="exportToCSV()">
|
||||
📊 Export CSV
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="exportToXLS()">
|
||||
📋 Export XLS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -392,7 +514,6 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Aircraft</th>
|
||||
<th>Type</th>
|
||||
@@ -425,6 +546,54 @@
|
||||
<p>No records match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Flights Table -->
|
||||
<div class="reports-table" style="margin-top: 2rem;">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<strong>Other Flights</strong>
|
||||
<div class="table-info" id="other-flights-info">Loading...</div>
|
||||
</div>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-success" onclick="exportOtherFlightsToCSV()">
|
||||
📊 Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="other-flights-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading flights...
|
||||
</div>
|
||||
|
||||
<div id="other-flights-table-content" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th>Aircraft</th>
|
||||
<th>Aircraft Type</th>
|
||||
<th>Callsign</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>ETA / ETD / Called</th>
|
||||
<th>Landed / Departed / QSY</th>
|
||||
<th>Circuits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="other-flights-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="other-flights-no-data" class="no-data" style="display: none;">
|
||||
<h3>No other flights found</h3>
|
||||
<p>No flights match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
@@ -434,22 +603,156 @@
|
||||
let currentUser = null;
|
||||
let accessToken = null;
|
||||
let currentPPRs = []; // Store current results for export
|
||||
let currentOtherFlights = []; // Store other flights for export
|
||||
let otherFlightsFilterType = null; // Track which non-PPR flight type is selected for filtering
|
||||
|
||||
// Load UI configuration from API
|
||||
async function loadUIConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/config');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
|
||||
// Update tower title
|
||||
const titleElement = document.getElementById('tower-title');
|
||||
if (titleElement && config.tag) {
|
||||
titleElement.innerHTML = `📊 Reports ${config.tag}`;
|
||||
}
|
||||
|
||||
// Update top bar gradient
|
||||
const topBar = document.querySelector('.top-bar');
|
||||
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
|
||||
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
|
||||
}
|
||||
|
||||
// Update page title
|
||||
if (config.tag) {
|
||||
document.title = `PPR Reports - ${config.tag}`;
|
||||
}
|
||||
|
||||
// Optionally indicate environment (e.g., add to title if not production)
|
||||
if (config.environment && config.environment !== 'production') {
|
||||
const envIndicator = ` (${config.environment.toUpperCase()})`;
|
||||
if (titleElement) {
|
||||
titleElement.innerHTML += envIndicator;
|
||||
}
|
||||
if (document.title) {
|
||||
document.title += envIndicator;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load UI config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the page
|
||||
async function initializePage() {
|
||||
loadUIConfig(); // Load UI configuration first
|
||||
await initializeAuth();
|
||||
setupDefaultDateRange();
|
||||
await loadReports();
|
||||
}
|
||||
|
||||
// Set default date range to current month
|
||||
// Set default date range to today
|
||||
function setupDefaultDateRange() {
|
||||
setDateRangeToday();
|
||||
}
|
||||
|
||||
// Toggle custom date range picker
|
||||
function toggleCustomRange() {
|
||||
const container = document.getElementById('custom-range-container');
|
||||
const customBtn = document.getElementById('filter-custom');
|
||||
|
||||
const isVisible = container.style.display !== 'none';
|
||||
container.style.display = isVisible ? 'none' : 'flex';
|
||||
|
||||
// Update button style
|
||||
if (isVisible) {
|
||||
customBtn.classList.remove('btn-primary');
|
||||
customBtn.classList.add('btn-secondary');
|
||||
} else {
|
||||
customBtn.classList.remove('btn-secondary');
|
||||
customBtn.classList.add('btn-primary');
|
||||
// Focus on the first date input when opening
|
||||
document.getElementById('date-from').focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Set date range to today
|
||||
function setDateRangeToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('date-from').value = today;
|
||||
document.getElementById('date-to').value = today;
|
||||
|
||||
// Hide custom range picker if it's open
|
||||
document.getElementById('custom-range-container').style.display = 'none';
|
||||
|
||||
updateFilterButtonStyles('today');
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// Set date range to this week (Monday to Sunday)
|
||||
function setDateRangeThisWeek() {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(now.setDate(diff));
|
||||
const sunday = new Date(now.setDate(diff + 6));
|
||||
|
||||
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||
|
||||
// Hide custom range picker if it's open
|
||||
document.getElementById('custom-range-container').style.display = 'none';
|
||||
|
||||
updateFilterButtonStyles('week');
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// Set date range to this month
|
||||
function setDateRangeThisMonth() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
|
||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||
|
||||
// Hide custom range picker if it's open
|
||||
document.getElementById('custom-range-container').style.display = 'none';
|
||||
|
||||
updateFilterButtonStyles('month');
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// Update button styles to show which filter is active
|
||||
function updateFilterButtonStyles(activeFilter) {
|
||||
const todayBtn = document.getElementById('filter-today');
|
||||
const weekBtn = document.getElementById('filter-week');
|
||||
const monthBtn = document.getElementById('filter-month');
|
||||
|
||||
// Reset all buttons
|
||||
[todayBtn, weekBtn, monthBtn].forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-secondary');
|
||||
});
|
||||
|
||||
// Highlight active button
|
||||
switch(activeFilter) {
|
||||
case 'today':
|
||||
todayBtn.classList.remove('btn-secondary');
|
||||
todayBtn.classList.add('btn-primary');
|
||||
break;
|
||||
case 'week':
|
||||
weekBtn.classList.remove('btn-secondary');
|
||||
weekBtn.classList.add('btn-primary');
|
||||
break;
|
||||
case 'month':
|
||||
monthBtn.classList.remove('btn-secondary');
|
||||
monthBtn.classList.add('btn-primary');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication management
|
||||
@@ -469,7 +772,7 @@
|
||||
}
|
||||
|
||||
// No valid cached token, redirect to admin
|
||||
window.location.href = 'admin.html';
|
||||
window.location.href = 'admin';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -478,13 +781,13 @@
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
accessToken = null;
|
||||
currentUser = null;
|
||||
window.location.href = 'admin.html';
|
||||
window.location.href = 'admin';
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper with token expiry handling
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
if (!accessToken) {
|
||||
window.location.href = 'admin.html';
|
||||
window.location.href = 'admin';
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
@@ -511,6 +814,9 @@
|
||||
document.getElementById('reports-loading').style.display = 'block';
|
||||
document.getElementById('reports-table-content').style.display = 'none';
|
||||
document.getElementById('reports-no-data').style.display = 'none';
|
||||
document.getElementById('other-flights-loading').style.display = 'block';
|
||||
document.getElementById('other-flights-table-content').style.display = 'none';
|
||||
document.getElementById('other-flights-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
const dateFrom = document.getElementById('date-from').value;
|
||||
@@ -524,13 +830,20 @@
|
||||
if (dateTo) url += `&date_to=${dateTo}`;
|
||||
if (status) url += `&status=${status}`;
|
||||
|
||||
const response = await authenticatedFetch(url);
|
||||
// Fetch all data in parallel
|
||||
const [pprResponse, arrivalsResponse, departuresResponse, localFlightsResponse, overflightsResponse] = await Promise.all([
|
||||
authenticatedFetch(url),
|
||||
authenticatedFetch(`/api/v1/arrivals/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
authenticatedFetch(`/api/v1/departures/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
authenticatedFetch(`/api/v1/local-flights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
authenticatedFetch(`/api/v1/overflights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`)
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!pprResponse.ok) {
|
||||
throw new Error('Failed to fetch PPR records');
|
||||
}
|
||||
|
||||
let pprs = await response.json();
|
||||
let pprs = await pprResponse.json();
|
||||
|
||||
// Apply client-side search filtering
|
||||
if (search) {
|
||||
@@ -546,6 +859,78 @@
|
||||
|
||||
currentPPRs = pprs; // Store for export
|
||||
displayReports(pprs);
|
||||
|
||||
// Process other flights
|
||||
let otherFlights = [];
|
||||
|
||||
if (arrivalsResponse.ok) {
|
||||
const arrivals = await arrivalsResponse.json();
|
||||
otherFlights.push(...arrivals.map(f => ({
|
||||
...f,
|
||||
flightType: 'ARRIVAL',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.eta || f.landed_dt,
|
||||
fromField: f.in_from,
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
}
|
||||
|
||||
if (departuresResponse.ok) {
|
||||
const departures = await departuresResponse.json();
|
||||
otherFlights.push(...departures.map(f => ({
|
||||
...f,
|
||||
flightType: 'DEPARTURE',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.etd || f.departed_dt,
|
||||
fromField: 'EGFH',
|
||||
toField: f.out_to
|
||||
})));
|
||||
}
|
||||
|
||||
if (localFlightsResponse.ok) {
|
||||
const localFlights = await localFlightsResponse.json();
|
||||
otherFlights.push(...localFlights.map(f => ({
|
||||
...f,
|
||||
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
||||
aircraft_type: f.type,
|
||||
circuits: f.circuits,
|
||||
timeField: f.departed_dt,
|
||||
fromField: 'EGFH',
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
}
|
||||
|
||||
if (overflightsResponse.ok) {
|
||||
const overflights = await overflightsResponse.json();
|
||||
otherFlights.push(...overflights.map(f => ({
|
||||
...f,
|
||||
flightType: 'OVERFLIGHT',
|
||||
aircraft_type: f.type,
|
||||
circuits: null,
|
||||
timeField: f.call_dt,
|
||||
fromField: f.departure_airfield,
|
||||
toField: f.destination_airfield,
|
||||
callsign: f.registration
|
||||
})));
|
||||
}
|
||||
|
||||
// Apply search filtering to other flights
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
otherFlights = otherFlights.filter(f =>
|
||||
(f.registration && f.registration.toLowerCase().includes(searchLower)) ||
|
||||
(f.callsign && f.callsign.toLowerCase().includes(searchLower)) ||
|
||||
(f.fromField && f.fromField.toLowerCase().includes(searchLower)) ||
|
||||
(f.toField && f.toField.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
currentOtherFlights = otherFlights;
|
||||
displayOtherFlights(otherFlights);
|
||||
|
||||
// Calculate and display movements summary
|
||||
calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error);
|
||||
if (error.message !== 'Session expired. Please log in again.') {
|
||||
@@ -554,6 +939,94 @@
|
||||
}
|
||||
|
||||
document.getElementById('reports-loading').style.display = 'none';
|
||||
document.getElementById('other-flights-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
// Calculate and display movements summary
|
||||
function calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo) {
|
||||
let pprArrivals = 0; // PPR landings
|
||||
let pprDepartures = 0; // PPR takeoffs
|
||||
let localFlightsMovements = 0;
|
||||
let circuitsMovements = 0;
|
||||
let nonPprArrivals = 0;
|
||||
let nonPprDepartures = 0;
|
||||
|
||||
// Format date range for display
|
||||
let dateRangeText = '';
|
||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||
// Single day
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
} else if (dateFrom && dateTo) {
|
||||
// Date range
|
||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
||||
const toDate = new Date(dateTo + 'T00:00:00Z');
|
||||
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
dateRangeText = `for ${fromText} to ${toText}`;
|
||||
} else if (dateFrom) {
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
} else if (dateTo) {
|
||||
const date = new Date(dateTo + 'T00:00:00Z');
|
||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
}
|
||||
|
||||
// Update summary title with date range
|
||||
const summaryTitle = document.querySelector('.summary-title');
|
||||
if (summaryTitle) {
|
||||
summaryTitle.textContent = `📊 Movements Summary ${dateRangeText}`;
|
||||
}
|
||||
|
||||
// PPR movements (excluding CANCELED):
|
||||
// - LANDED = 1 arrival (landing)
|
||||
// - DEPARTED = 1 departure + 1 arrival (because departure implies a prior landing)
|
||||
pprs.filter(ppr => ppr.status !== 'CANCELED').forEach(ppr => {
|
||||
if (ppr.status === 'LANDED') {
|
||||
pprArrivals += 1;
|
||||
} else if (ppr.status === 'DEPARTED') {
|
||||
pprDepartures += 1;
|
||||
pprArrivals += 1; // Each departure implies a landing happened
|
||||
}
|
||||
});
|
||||
|
||||
// Other flights movements (excluding CANCELLED)
|
||||
let overflightCount = 0;
|
||||
otherFlights.filter(flight => flight.status !== 'CANCELLED').forEach(flight => {
|
||||
if (flight.flightType === 'ARRIVAL') {
|
||||
nonPprArrivals += 1;
|
||||
} else if (flight.flightType === 'DEPARTURE') {
|
||||
nonPprDepartures += 1;
|
||||
} else if (flight.flightType === 'LOCAL') {
|
||||
// 2 movements (takeoff + landing) for the flight itself
|
||||
localFlightsMovements += 2;
|
||||
} else if (flight.flightType === 'CIRCUIT') {
|
||||
// 2 movements (takeoff + landing) plus the circuit count
|
||||
const circuits = flight.circuits || 0;
|
||||
circuitsMovements += 2 + circuits;
|
||||
} else if (flight.flightType === 'OVERFLIGHT') {
|
||||
// 1 movement for each overflight (they're just talking to tower)
|
||||
overflightCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const pprTotal = pprArrivals + pprDepartures;
|
||||
const nonPprTotal = localFlightsMovements + circuitsMovements + nonPprArrivals + nonPprDepartures + overflightCount;
|
||||
const grandTotal = pprTotal + nonPprTotal;
|
||||
|
||||
// Update the summary display
|
||||
document.getElementById('ppr-arrivals').textContent = pprArrivals;
|
||||
document.getElementById('ppr-departures').textContent = pprDepartures;
|
||||
document.getElementById('ppr-total').textContent = pprTotal;
|
||||
document.getElementById('overflights-count').textContent = overflightCount;
|
||||
|
||||
document.getElementById('local-flights-movements').textContent = localFlightsMovements;
|
||||
document.getElementById('circuits-movements').textContent = circuitsMovements;
|
||||
document.getElementById('non-ppr-arrivals').textContent = nonPprArrivals;
|
||||
document.getElementById('non-ppr-departures').textContent = nonPprDepartures;
|
||||
document.getElementById('non-ppr-total').textContent = nonPprTotal;
|
||||
|
||||
document.getElementById('grand-total-movements').textContent = grandTotal;
|
||||
}
|
||||
|
||||
// Display reports in table
|
||||
@@ -568,6 +1041,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by ETA (ascending)
|
||||
pprs.sort((a, b) => {
|
||||
if (!a.eta) return 1;
|
||||
if (!b.eta) return -1;
|
||||
return new Date(a.eta) - new Date(b.eta);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('reports-table-content').style.display = 'block';
|
||||
|
||||
@@ -586,7 +1066,6 @@
|
||||
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${ppr.id}</td>
|
||||
<td><span class="${statusClass}">${statusText}</span></td>
|
||||
<td>${ppr.ac_reg}</td>
|
||||
<td>${ppr.ac_type}</td>
|
||||
@@ -612,6 +1091,156 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter other flights by type
|
||||
function filterOtherFlights(flightType) {
|
||||
// Toggle filter if clicking the same type
|
||||
if (otherFlightsFilterType === flightType) {
|
||||
otherFlightsFilterType = null;
|
||||
} else {
|
||||
otherFlightsFilterType = flightType;
|
||||
}
|
||||
|
||||
// Show/hide PPR section based on filter
|
||||
const pprSection = document.getElementById('ppr-reports-section');
|
||||
if (pprSection) {
|
||||
pprSection.style.display = otherFlightsFilterType ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Update visual indication of active filter
|
||||
updateFilterIndicators();
|
||||
|
||||
// Re-display flights with new filter
|
||||
displayOtherFlights(currentOtherFlights);
|
||||
}
|
||||
|
||||
// Update visual indicators for active filter
|
||||
function updateFilterIndicators() {
|
||||
// Select all clickable non-PPR summary items (those with onclick attribute)
|
||||
const summaryItems = document.querySelectorAll('.summary-item[onclick*="filterOtherFlights"]');
|
||||
summaryItems.forEach(item => {
|
||||
item.style.opacity = '1';
|
||||
item.style.borderLeftColor = '';
|
||||
item.style.borderLeftWidth = '0';
|
||||
});
|
||||
|
||||
if (otherFlightsFilterType) {
|
||||
// Get the ID of the selected filter's summary item
|
||||
let selectedId = '';
|
||||
switch(otherFlightsFilterType) {
|
||||
case 'LOCAL':
|
||||
selectedId = 'local-flights-movements';
|
||||
break;
|
||||
case 'CIRCUIT':
|
||||
selectedId = 'circuits-movements';
|
||||
break;
|
||||
case 'ARRIVAL':
|
||||
selectedId = 'non-ppr-arrivals';
|
||||
break;
|
||||
case 'DEPARTURE':
|
||||
selectedId = 'non-ppr-departures';
|
||||
break;
|
||||
case 'OVERFLIGHT':
|
||||
selectedId = 'overflights-count';
|
||||
break;
|
||||
}
|
||||
|
||||
// Find and highlight the selected item
|
||||
if (selectedId) {
|
||||
const selectedElement = document.getElementById(selectedId);
|
||||
if (selectedElement) {
|
||||
const summaryItem = selectedElement.closest('.summary-item');
|
||||
if (summaryItem) {
|
||||
summaryItem.style.borderLeftColor = '#4CAF50';
|
||||
summaryItem.style.borderLeftWidth = '4px';
|
||||
summaryItem.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dim other items that are clickable (non-PPR items)
|
||||
const allSummaryItems = document.querySelectorAll('.summary-item[onclick]');
|
||||
allSummaryItems.forEach(item => {
|
||||
if (item.querySelector('#' + selectedId) === null) {
|
||||
item.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Display other flights in table
|
||||
function displayOtherFlights(flights) {
|
||||
const tbody = document.getElementById('other-flights-table-body');
|
||||
const tableInfo = document.getElementById('other-flights-info');
|
||||
|
||||
// Apply filter if one is selected
|
||||
let filteredFlights = flights;
|
||||
if (otherFlightsFilterType) {
|
||||
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
|
||||
}
|
||||
|
||||
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
|
||||
|
||||
if (filteredFlights.length === 0) {
|
||||
document.getElementById('other-flights-no-data').style.display = 'block';
|
||||
document.getElementById('other-flights-table-content').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by time field (ascending)
|
||||
filteredFlights.sort((a, b) => {
|
||||
const aTime = a.timeField;
|
||||
const bTime = b.timeField;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('other-flights-table-content').style.display = 'block';
|
||||
document.getElementById('other-flights-no-data').style.display = 'none';
|
||||
|
||||
for (const flight of filteredFlights) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const typeLabel = flight.flightType;
|
||||
const registration = flight.registration || '-';
|
||||
const aircraftType = flight.aircraft_type || '-';
|
||||
const callsign = flight.callsign || '-';
|
||||
const from = flight.fromField || '-';
|
||||
const to = flight.toField || '-';
|
||||
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
|
||||
|
||||
// Different display for different flight types
|
||||
let actualDisplay = '-';
|
||||
if (flight.flightType === 'ARRIVAL') {
|
||||
actualDisplay = flight.landed_dt ? formatDateTime(flight.landed_dt) : '-';
|
||||
} else if (flight.flightType === 'OVERFLIGHT') {
|
||||
// For overflights, show qsy_dt (frequency change time)
|
||||
actualDisplay = flight.qsy_dt ? formatDateTime(flight.qsy_dt) : '-';
|
||||
} else {
|
||||
actualDisplay = flight.departed_dt ? formatDateTime(flight.departed_dt) : '-';
|
||||
}
|
||||
|
||||
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
|
||||
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><span class="status ${status.toLowerCase()}">${status}</span></td>
|
||||
<td><strong>${typeLabel}</strong></td>
|
||||
<td>${registration}</td>
|
||||
<td>${aircraftType}</td>
|
||||
<td>${callsign}</td>
|
||||
<td>${from}</td>
|
||||
<td>${to}</td>
|
||||
<td>${timeDisplay}</td>
|
||||
<td>${actualDisplay}</td>
|
||||
<td>${circuits}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
let utcDateStr = dateStr;
|
||||
@@ -622,7 +1251,15 @@
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Clear filters
|
||||
@@ -672,16 +1309,33 @@
|
||||
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
||||
}
|
||||
|
||||
function exportToXLS() {
|
||||
if (currentPPRs.length === 0) {
|
||||
function exportOtherFlightsToCSV() {
|
||||
if (currentOtherFlights.length === 0) {
|
||||
showNotification('No data to export', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For XLS export, we'll create a CSV that Excel can open
|
||||
// In a production environment, you'd want to use a proper XLS library
|
||||
exportToCSV();
|
||||
showNotification('XLS export uses CSV format (compatible with Excel)');
|
||||
const headers = [
|
||||
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
|
||||
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
|
||||
];
|
||||
|
||||
const csvData = currentOtherFlights.map(flight => [
|
||||
flight.flightType,
|
||||
flight.registration || '',
|
||||
flight.aircraft_type || '',
|
||||
flight.callsign || '',
|
||||
flight.fromField || '',
|
||||
flight.toField || '',
|
||||
flight.timeField ? formatDateTime(flight.timeField) : '',
|
||||
flight.flightType === 'ARRIVAL'
|
||||
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
|
||||
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
|
||||
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
|
||||
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
|
||||
]);
|
||||
|
||||
downloadCSV(headers, csvData, 'other_flights_reports.csv');
|
||||
}
|
||||
|
||||
function downloadCSV(headers, data, filename) {
|
||||
|
||||
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")}})}
|
||||