Compare commits

..

57 Commits

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

View File

@@ -1,8 +1,8 @@
# Database Configuration # Database Configuration
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here 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_PASSWORD=your_database_password_here
DB_NAME=ppr DB_NAME=your_database_name_here
DB_PORT=3306 DB_PORT=3306
# API Configuration # API Configuration
@@ -13,6 +13,25 @@ API_V1_STR=/api/v1
PROJECT_NAME=Airfield PPR API NextGen PROJECT_NAME=Airfield PPR API NextGen
API_PORT_EXTERNAL=8001 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 Configuration
WEB_PORT_EXTERNAL=8082 WEB_PORT_EXTERNAL=8082

7
.gitignore vendored
View File

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

View File

@@ -28,24 +28,72 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
### Prerequisites ### Prerequisites
- Docker and Docker Compose installed - Docker and Docker Compose installed
### 1. Start the System ### Development Setup
**Start the system (automatic database setup):**
```bash ```bash
cd nextgen docker compose up -d
./start.sh
``` ```
That's it! The container automatically:
- Waits for database to be ready
- Creates schema via Alembic migrations
- Loads airport and aircraft reference data
- Starts the API with auto-reload
**View startup logs:**
```bash
docker compose logs -f api
```
### Production Deployment
**Simple automated deployment:**
```bash
# 1. Configure environment
cp .env.example .env
nano .env # Set your external database credentials
# 2. Start with production settings
docker compose -f docker-compose.prod.yml up -d
```
The container automatically handles:
- Database connection verification
- Schema creation/migration (Alembic)
- Reference data seeding (if needed)
- Production server startup (4 workers)
**Monitor deployment:**
```bash
docker compose -f docker-compose.prod.yml logs -f api
```
**Deploying updates with migrations:**
```bash
git pull
docker compose -f docker-compose.prod.yml up -d --build
# Migrations apply automatically!
```
**See detailed guides:**
- [`AUTOMATED_MIGRATION_GUIDE.md`](./AUTOMATED_MIGRATION_GUIDE.md) - How automatic migrations work
- [`PRODUCTION_MIGRATION_GUIDE.md`](./PRODUCTION_MIGRATION_GUIDE.md) - Advanced migration strategies
- [`MIGRATION_QUICK_REF.md`](./MIGRATION_QUICK_REF.md) - Quick reference commands
### 2. Access the Services ### 2. Access the Services
- **API Documentation**: http://localhost:8001/docs - **API Documentation**: http://localhost:8001/docs
- **API Base URL**: http://localhost:8001/api/v1 - **API Base URL**: http://localhost:8001/api/v1
- **Public Web Interface**: http://localhost:8082 - **Public Web Interface**: http://localhost:8082
- **Admin Interface**: http://localhost:8082/admin.html - **Admin Interface**: http://localhost:8082/admin.html
- **Reports Interface**: http://localhost:8082/reports.html - **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 - **phpMyAdmin**: http://localhost:8083
### 3. Default Login ### 3. Default Login
- **Username**: admin - **Username**: admin
- **Password**: admin123 - **Password**: [configured in database - see init_db.sql]
## API Endpoints ## API Endpoints
@@ -129,12 +177,7 @@ uvicorn app.main:app --reload
# Connect to database # Connect to database
docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
# View logs # When prompted for password, use the value from .env (DB_PASSWORD)
docker-compose logs -f api
docker-compose logs -f db
# Restart services
docker-compose restart
``` ```
### Testing ### Testing

View File

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

View File

@@ -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
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

190
backend/entrypoint.sh Normal file
View File

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

174
backend/seed_data.py Normal file
View File

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

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

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

View File

@@ -30,16 +30,46 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
API_V1_STR: ${API_V1_STR} API_V1_STR: ${API_V1_STR}
PROJECT_NAME: ${PROJECT_NAME} PROJECT_NAME: ${PROJECT_NAME}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: ${ENVIRONMENT}
ports: ports:
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system - "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
depends_on: depends_on:
- db - db
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
networks: networks:
- private_network - private_network
- public_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 for caching (optional for now)
redis: redis:
@@ -49,21 +79,6 @@ services:
networks: networks:
- private_network - 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 for database management
phpmyadmin: phpmyadmin:
image: phpmyadmin/phpmyadmin image: phpmyadmin/phpmyadmin

View File

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

762
web/admin.css Normal file
View File

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

File diff suppressed because it is too large Load Diff

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

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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

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

After

Width:  |  Height:  |  Size: 546 B

BIN
web/assets/flightImg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

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

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

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
web/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

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

After

Width:  |  Height:  |  Size: 1.4 KiB

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

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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

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

After

Width:  |  Height:  |  Size: 758 B

File diff suppressed because it is too large Load Diff

503
web/lookups.js Normal file
View File

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

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swansea PPR Request</title> <title>Swansea PPR</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -254,13 +254,91 @@
color: #6c757d; color: #6c757d;
font-size: 0.85rem; 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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>✈️ Swansea Airport PPR Request</h1> <h1>✈️ PPR Request</h1>
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</p> <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> </div>
<form id="ppr-form"> <form id="ppr-form">
@@ -306,7 +384,6 @@
<option value="">None</option> <option value="">None</option>
<option value="100LL">100LL</option> <option value="100LL">100LL</option>
<option value="JET A1">JET A1</option> <option value="JET A1">JET A1</option>
<option value="FULL">Full Tanks</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -354,16 +431,83 @@
</div> </div>
<div class="success-message" id="success-message"> <div class="success-message" id="success-message">
<h3>PPR Request Submitted Successfully!</h3> <h3>PPR Request Submitted.</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>Your PPR request has been submitted. 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> <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>
</div> </div>
<!-- Success Notification --> <!-- Success Notification -->
<div id="notification" class="notification"></div> <div id="notification" class="notification"></div>
<script src="/config.js"></script>
<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 // Initialize time dropdowns
function initializeTimeDropdowns() { function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time']; const timeSelects = ['eta-time', 'etd-time'];
@@ -395,11 +539,15 @@
// Show notification // Show notification
setTimeout(() => { setTimeout(() => {
notification.classList.add('show'); notification.classList.add('show');
// Update iframe height when notification appears
setTimeout(sendHeightToParent, 50);
}, 10); }, 10);
// Hide after 5 seconds // Hide after 5 seconds
setTimeout(() => { setTimeout(() => {
notification.classList.remove('show'); notification.classList.remove('show');
// Update iframe height when notification disappears
setTimeout(sendHeightToParent, 50);
}, 5000); }, 5000);
} }
@@ -419,7 +567,7 @@
aircraftLookupTimeout = setTimeout(async () => { aircraftLookupTimeout = setTimeout(async () => {
try { 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
@@ -498,17 +646,19 @@
arrivalAirportLookupTimeout = setTimeout(async () => { arrivalAirportLookupTimeout = setTimeout(async () => {
try { 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
if (data.length === 1) { if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code // Single match - show as clickable option
const airport = data[0]; const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="lookup-match"> <div class="aircraft-list">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country} <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> </div>
`; `;
} else if (data.length <= 10) { } else if (data.length <= 10) {
@@ -555,17 +705,19 @@
departureAirportLookupTimeout = setTimeout(async () => { departureAirportLookupTimeout = setTimeout(async () => {
try { 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
if (data.length === 1) { if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code // Single match - show as clickable option
const airport = data[0]; const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="lookup-match"> <div class="aircraft-list">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country} <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> </div>
`; `;
} else if (data.length <= 10) { } else if (data.length <= 10) {
@@ -653,7 +805,7 @@
document.getElementById('submit-btn').textContent = 'Submitting...'; document.getElementById('submit-btn').textContent = 'Submitting...';
try { try {
const response = await fetch('/api/v1/pprs/public', { const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -670,6 +822,8 @@
document.getElementById('success-message').style.display = 'block'; document.getElementById('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!'); showNotification('PPR request submitted successfully!');
// Update iframe height after content change
setTimeout(sendHeightToParent, 100);
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`); throw new Error(errorData.detail || `Submission failed: ${response.status}`);
@@ -677,6 +831,8 @@
} catch (error) { } catch (error) {
console.error('Error submitting PPR:', error); console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true); showNotification(`Error submitting PPR: ${error.message}`, true);
// Update iframe height after showing error
setTimeout(sendHeightToParent, 100);
} finally { } finally {
// Hide loading // Hide loading
document.getElementById('loading').style.display = 'none'; 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 // Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initializeTimeDropdowns(); 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> </script>
</body> </body>

View File

@@ -22,27 +22,37 @@
color: white; color: white;
padding: 0.5rem 2rem; padding: 0.5rem 2rem;
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center; align-items: center;
gap: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: 0 2px 10px rgba(0,0,0,0.1);
} }
.title {
order: 2;
flex: 1;
text-align: center;
}
.title h1 { .title h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
} }
.menu-buttons { .menu-buttons {
order: 1;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
} }
.top-bar .user-info { .top-bar .user-info {
order: 3;
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.9; opacity: 0.9;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.3rem;
} }
.container { .container {
@@ -226,6 +236,45 @@
.status.canceled { background: #ffebee; color: #d32f2f; } .status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; } .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 { .no-data {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
@@ -312,14 +361,14 @@
</head> </head>
<body> <body>
<div class="top-bar"> <div class="top-bar">
<div class="title">
<h1>📊 PPR Reports</h1>
</div>
<div class="menu-buttons"> <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 ← Back to Admin
</button> </button>
</div> </div>
<div class="title">
<h1 id="tower-title">📊 PPR Reports</h1>
</div>
<div class="user-info"> <div class="user-info">
Logged in as: <span id="current-user">Loading...</span> | Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a> <a href="#" onclick="logout()" style="color: white;">Logout</a>
@@ -329,18 +378,26 @@
<div class="container"> <div class="container">
<!-- Filters Section --> <!-- Filters Section -->
<div class="filters-section"> <div class="filters-section">
<div class="filters-grid"> <div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
<div class="filter-group"> <!-- Quick Filter Buttons -->
<label for="date-from">Date From:</label> <div style="display: flex; gap: 0.5rem;">
<input type="date" id="date-from"> <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>
<div class="filter-group">
<label for="date-to">Date To:</label> <!-- Custom Date Range (hidden by default) -->
<input type="date" id="date-to"> <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>
<div class="filter-group">
<label for="status-filter">Status:</label> <!-- Status Filter -->
<select id="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="">All Statuses</option>
<option value="NEW">New</option> <option value="NEW">New</option>
<option value="CONFIRMED">Confirmed</option> <option value="CONFIRMED">Confirmed</option>
@@ -350,23 +407,91 @@
<option value="DELETED">Deleted</option> <option value="DELETED">Deleted</option>
</select> </select>
</div> </div>
<div class="filter-group">
<label for="search-input">Search:</label> <!-- Search Input -->
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..."> <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>
<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 🔍 Search
</button> </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 🗑️ Clear
</button> </button>
</div> </div>
</div> </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 --> <!-- Reports Table -->
<div class="reports-table"> <div class="reports-table" id="ppr-reports-section">
<div class="table-header"> <div class="table-header">
<div> <div>
<strong>PPR Records</strong> <strong>PPR Records</strong>
@@ -376,9 +501,6 @@
<button class="btn btn-success" onclick="exportToCSV()"> <button class="btn btn-success" onclick="exportToCSV()">
📊 Export CSV 📊 Export CSV
</button> </button>
<button class="btn btn-success" onclick="exportToXLS()">
📋 Export XLS
</button>
</div> </div>
</div> </div>
@@ -392,7 +514,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th>
<th>Status</th> <th>Status</th>
<th>Aircraft</th> <th>Aircraft</th>
<th>Type</th> <th>Type</th>
@@ -425,6 +546,54 @@
<p>No records match your current filters.</p> <p>No records match your current filters.</p>
</div> </div>
</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> </div>
<!-- Success Notification --> <!-- Success Notification -->
@@ -434,22 +603,156 @@
let currentUser = null; let currentUser = null;
let accessToken = null; let accessToken = null;
let currentPPRs = []; // Store current results for export 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 // Initialize the page
async function initializePage() { async function initializePage() {
loadUIConfig(); // Load UI configuration first
await initializeAuth(); await initializeAuth();
setupDefaultDateRange(); setupDefaultDateRange();
await loadReports(); await loadReports();
} }
// Set default date range to current month // Set default date range to today
function setupDefaultDateRange() { 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 now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0); const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0]; document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
document.getElementById('date-to').value = lastDay.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 // Authentication management
@@ -469,7 +772,7 @@
} }
// No valid cached token, redirect to admin // No valid cached token, redirect to admin
window.location.href = 'admin.html'; window.location.href = 'admin';
} }
function logout() { function logout() {
@@ -478,13 +781,13 @@
localStorage.removeItem('ppr_token_expiry'); localStorage.removeItem('ppr_token_expiry');
accessToken = null; accessToken = null;
currentUser = null; currentUser = null;
window.location.href = 'admin.html'; window.location.href = 'admin';
} }
// Enhanced fetch wrapper with token expiry handling // Enhanced fetch wrapper with token expiry handling
async function authenticatedFetch(url, options = {}) { async function authenticatedFetch(url, options = {}) {
if (!accessToken) { if (!accessToken) {
window.location.href = 'admin.html'; window.location.href = 'admin';
throw new Error('No access token available'); throw new Error('No access token available');
} }
@@ -511,6 +814,9 @@
document.getElementById('reports-loading').style.display = 'block'; document.getElementById('reports-loading').style.display = 'block';
document.getElementById('reports-table-content').style.display = 'none'; document.getElementById('reports-table-content').style.display = 'none';
document.getElementById('reports-no-data').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 { try {
const dateFrom = document.getElementById('date-from').value; const dateFrom = document.getElementById('date-from').value;
@@ -524,13 +830,20 @@
if (dateTo) url += `&date_to=${dateTo}`; if (dateTo) url += `&date_to=${dateTo}`;
if (status) url += `&status=${status}`; 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'); throw new Error('Failed to fetch PPR records');
} }
let pprs = await response.json(); let pprs = await pprResponse.json();
// Apply client-side search filtering // Apply client-side search filtering
if (search) { if (search) {
@@ -546,6 +859,78 @@
currentPPRs = pprs; // Store for export currentPPRs = pprs; // Store for export
displayReports(pprs); 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) { } catch (error) {
console.error('Error loading reports:', error); console.error('Error loading reports:', error);
if (error.message !== 'Session expired. Please log in again.') { if (error.message !== 'Session expired. Please log in again.') {
@@ -554,6 +939,94 @@
} }
document.getElementById('reports-loading').style.display = 'none'; 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 // Display reports in table
@@ -568,6 +1041,13 @@
return; 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 = ''; tbody.innerHTML = '';
document.getElementById('reports-table-content').style.display = 'block'; document.getElementById('reports-table-content').style.display = 'block';
@@ -586,7 +1066,6 @@
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase(); const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
row.innerHTML = ` row.innerHTML = `
<td>${ppr.id}</td>
<td><span class="${statusClass}">${statusText}</span></td> <td><span class="${statusClass}">${statusText}</span></td>
<td>${ppr.ac_reg}</td> <td>${ppr.ac_reg}</td>
<td>${ppr.ac_type}</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) { function formatDateTime(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
let utcDateStr = dateStr; let utcDateStr = dateStr;
@@ -622,7 +1251,15 @@
utcDateStr += 'Z'; utcDateStr += 'Z';
} }
const date = new Date(utcDateStr); 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 // Clear filters
@@ -672,16 +1309,33 @@
downloadCSV(headers, csvData, 'ppr_reports.csv'); downloadCSV(headers, csvData, 'ppr_reports.csv');
} }
function exportToXLS() { function exportOtherFlightsToCSV() {
if (currentPPRs.length === 0) { if (currentOtherFlights.length === 0) {
showNotification('No data to export', true); showNotification('No data to export', true);
return; return;
} }
// For XLS export, we'll create a CSV that Excel can open const headers = [
// In a production environment, you'd want to use a proper XLS library 'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
exportToCSV(); 'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
showNotification('XLS export uses CSV format (compatible with Excel)'); ];
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) { function downloadCSV(headers, data, filename) {

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

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