Compare commits
23 Commits
stable
...
10ab215396
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ab215396 | |||
| a9b5ec67ba | |||
| 733e9b426f | |||
| 044ce40e69 | |||
| fc394b8555 | |||
| 78d738b0ee | |||
| 1952b89ecf | |||
| 7b2de645db | |||
| dee58e0aae | |||
| 2dce14507b | |||
| 9867156334 | |||
| eb2321ef40 | |||
| bb6597ff76 | |||
| 423023d3d9 | |||
| fd0e521186 | |||
| d2c9bc0370 | |||
| bddbe1451f | |||
| 785562407a | |||
| 5bb229ad78 | |||
| 8a2dd5544c | |||
| 3a4085afc6 | |||
| a43cf9b732 | |||
| 7f4e4a8459 |
@@ -1,3 +1,5 @@
|
||||
web/assets/booking-qr.png
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -6,7 +6,6 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
|
||||
|
||||
- **Backend**: FastAPI with Python 3.11
|
||||
- **Database**: MySQL 8.0
|
||||
- **Cache**: Redis 7
|
||||
- **Container**: Docker & Docker Compose
|
||||
|
||||
## Features
|
||||
@@ -63,7 +62,7 @@ The container automatically handles:
|
||||
- Database connection verification
|
||||
- Schema creation/migration (Alembic)
|
||||
- Reference data seeding (if needed)
|
||||
- Production server startup (4 workers)
|
||||
- Production server startup (single worker for in-process WebSocket broadcasts)
|
||||
|
||||
**Monitor deployment:**
|
||||
```bash
|
||||
@@ -181,9 +180,32 @@ docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
||||
```
|
||||
|
||||
### Testing
|
||||
The backend API tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database with dependency overrides for database sessions and authenticated users.
|
||||
|
||||
See [`backend/tests/README.md`](./backend/tests/README.md) for a module-by-module explanation of what the tests cover and why.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/
|
||||
pytest
|
||||
```
|
||||
|
||||
Or, with the Docker development stack running:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest
|
||||
```
|
||||
|
||||
To inspect API test coverage:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
Or in Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Additional Features
|
||||
@@ -266,7 +288,6 @@ This ensures consistency across different time zones and complies with aviation
|
||||
|
||||
- Database connection pooling
|
||||
- Indexed columns for fast queries
|
||||
- Redis caching (ready for implementation)
|
||||
- Async/await for non-blocking operations
|
||||
|
||||
## Monitoring
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
TODO
|
||||
|
||||
Allow corrections
|
||||
|
||||
Post-strip reporting
|
||||
|
||||
Implement mark's 'tick off the PPRs' in the old admin screen
|
||||
|
||||
Define schema for 'movements' table. We generate movement records as they happen so as not to reply on maths
|
||||
|
||||
Flow to create an arrival and maybe departure from a PPR. Perhaps we need a correlation column somewhere
|
||||
|
||||
Ability to add a position report to a strip
|
||||
|
||||
Improve journaling
|
||||
|
||||
+2
-1
@@ -3,11 +3,12 @@ FROM python:3.11-slim
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies including qrencode
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
qrencode \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Add public booking support with submitted_via and pilot_email columns
|
||||
|
||||
Revision ID: 003_public_booking
|
||||
Revises: 002_local_flights
|
||||
Create Date: 2026-02-20 12:00:00.000000
|
||||
|
||||
This migration adds support for public flight booking by adding:
|
||||
- submitted_via enum field to track ADMIN vs PUBLIC submissions
|
||||
- pilot_email field to store contact info for public submissions
|
||||
- Indexes on submitted_via for filtering queries
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003_public_booking'
|
||||
down_revision = '002_local_flights'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Add public booking support columns to local_flights, departures, and arrivals tables.
|
||||
"""
|
||||
|
||||
# Create the SubmissionSource enum type
|
||||
submission_source_enum = sa.Enum('ADMIN', 'PUBLIC', name='submissionsource')
|
||||
|
||||
# Add submitted_via and pilot_email to local_flights table
|
||||
op.add_column('local_flights', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
|
||||
op.add_column('local_flights', sa.Column('pilot_email', sa.String(length=128), nullable=True))
|
||||
|
||||
# Add indexes for submitted_via and pilot_email on local_flights
|
||||
op.create_index('idx_lf_submitted_via', 'local_flights', ['submitted_via'])
|
||||
op.create_index('idx_lf_pilot_email', 'local_flights', ['pilot_email'])
|
||||
|
||||
# Add submitted_via and pilot_email to departures table
|
||||
op.add_column('departures', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
|
||||
op.add_column('departures', sa.Column('pilot_email', sa.String(length=128), nullable=True))
|
||||
|
||||
# Add indexes for submitted_via and pilot_email on departures
|
||||
op.create_index('idx_dep_submitted_via', 'departures', ['submitted_via'])
|
||||
op.create_index('idx_dep_pilot_email', 'departures', ['pilot_email'])
|
||||
|
||||
# Add submitted_via and pilot_email to arrivals table
|
||||
op.add_column('arrivals', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
|
||||
op.add_column('arrivals', sa.Column('pilot_email', sa.String(length=128), nullable=True))
|
||||
|
||||
# Add indexes for submitted_via and pilot_email on arrivals
|
||||
op.create_index('idx_arr_submitted_via', 'arrivals', ['submitted_via'])
|
||||
op.create_index('idx_arr_pilot_email', 'arrivals', ['pilot_email'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Remove the submitted_via and pilot_email columns from local_flights, departures, and arrivals tables.
|
||||
"""
|
||||
|
||||
# Drop indexes first
|
||||
op.drop_index('idx_lf_submitted_via', table_name='local_flights')
|
||||
op.drop_index('idx_lf_pilot_email', table_name='local_flights')
|
||||
op.drop_index('idx_dep_submitted_via', table_name='departures')
|
||||
op.drop_index('idx_dep_pilot_email', table_name='departures')
|
||||
op.drop_index('idx_arr_submitted_via', table_name='arrivals')
|
||||
op.drop_index('idx_arr_pilot_email', table_name='arrivals')
|
||||
|
||||
# Drop columns from local_flights
|
||||
op.drop_column('local_flights', 'pilot_email')
|
||||
op.drop_column('local_flights', 'submitted_via')
|
||||
|
||||
# Drop columns from departures
|
||||
op.drop_column('departures', 'pilot_email')
|
||||
op.drop_column('departures', 'submitted_via')
|
||||
|
||||
# Drop columns from arrivals
|
||||
op.drop_column('arrivals', 'pilot_email')
|
||||
op.drop_column('arrivals', 'submitted_via')
|
||||
|
||||
# Drop the enum type
|
||||
op.execute('DROP TYPE IF EXISTS submissionsource')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add user_aircraft table for user-defined aircraft types
|
||||
|
||||
Revision ID: 004_user_aircraft
|
||||
Revises: 003_public_booking
|
||||
Create Date: 2026-03-23 12:00:00.000000
|
||||
|
||||
This migration adds a user_aircraft table to store aircraft types
|
||||
that are manually entered by users when not found in the main aircraft database.
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '004_user_aircraft'
|
||||
down_revision = '003_public_booking'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Create user_aircraft table for storing user-defined aircraft types.
|
||||
"""
|
||||
op.create_table('user_aircraft',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('registration', sa.String(length=25), nullable=False),
|
||||
sa.Column('type_code', sa.String(length=30), nullable=False),
|
||||
sa.Column('clean_reg', sa.String(length=25), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=16), nullable=False),
|
||||
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'), onupdate=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('registration')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('idx_user_aircraft_registration', 'user_aircraft', ['registration'])
|
||||
op.create_index('idx_user_aircraft_clean_reg', 'user_aircraft', ['clean_reg'])
|
||||
op.create_index('idx_user_aircraft_created_by', 'user_aircraft', ['created_by'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Drop user_aircraft table.
|
||||
"""
|
||||
op.drop_index('idx_user_aircraft_created_by', table_name='user_aircraft')
|
||||
op.drop_index('idx_user_aircraft_clean_reg', table_name='user_aircraft')
|
||||
op.drop_index('idx_user_aircraft_registration', table_name='user_aircraft')
|
||||
op.drop_table('user_aircraft')
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Add granular flight states and timestamps
|
||||
|
||||
Revision ID: 8adefaee847c
|
||||
Revises: 004_user_aircraft
|
||||
Create Date: 2026-03-24 09:09:00.944815
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '005_flight_states'
|
||||
down_revision = '004_user_aircraft'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add GROUND and LOCAL to local_flights status enum
|
||||
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
|
||||
|
||||
# Add timestamp columns to local_flights
|
||||
op.add_column('local_flights', sa.Column('contact_dt', sa.DateTime(), nullable=True))
|
||||
op.add_column('local_flights', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
|
||||
|
||||
# Add GROUND and ARRIVED to arrivals status enum
|
||||
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','INBOUND','LANDED','GROUND','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','ARRIVED','CANCELLED')")
|
||||
|
||||
# Add timestamp column to arrivals
|
||||
op.add_column('arrivals', sa.Column('arrived_dt', sa.DateTime(), nullable=True))
|
||||
|
||||
# Add GROUND and LOCAL to departures status enum
|
||||
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
|
||||
|
||||
# Add timestamp columns to departures
|
||||
op.add_column('departures', sa.Column('contact_dt', sa.DateTime(), nullable=True))
|
||||
op.add_column('departures', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
|
||||
|
||||
# Add arrival_id column to circuits table to support circuit logging for arrivals
|
||||
op.add_column('circuits', sa.Column('arrival_id', sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key('fk_circuits_arrival_id', 'circuits', 'arrivals', ['arrival_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_index('idx_circuit_arrival_id', 'circuits', ['arrival_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove arrival_id column from circuits table
|
||||
op.drop_constraint('fk_circuits_arrival_id', 'circuits', type_='foreignkey')
|
||||
op.drop_index('idx_circuit_arrival_id', table_name='circuits')
|
||||
op.drop_column('circuits', 'arrival_id')
|
||||
|
||||
# Update departures with new status values to valid old values before modifying enum
|
||||
op.execute("UPDATE departures SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL')")
|
||||
|
||||
# Remove timestamp columns from departures
|
||||
op.drop_column('departures', 'takeoff_dt')
|
||||
op.drop_column('departures', 'contact_dt')
|
||||
|
||||
# Remove GROUND and LOCAL from departures status enum
|
||||
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','CANCELLED')")
|
||||
|
||||
# Update arrivals with new status values to valid old values before modifying enum
|
||||
op.execute("UPDATE arrivals SET status = 'LANDED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT', 'ARRIVED')")
|
||||
|
||||
# Remove timestamp column from arrivals
|
||||
op.drop_column('arrivals', 'arrived_dt')
|
||||
|
||||
# Remove GROUND and ARRIVED from arrivals status enum
|
||||
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','LANDED','CANCELLED')")
|
||||
|
||||
# Update local_flights with new status values to valid old values before modifying enum
|
||||
op.execute("UPDATE local_flights SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT')")
|
||||
|
||||
# Remove timestamp columns from local_flights
|
||||
op.drop_column('local_flights', 'takeoff_dt')
|
||||
op.drop_column('local_flights', 'contact_dt')
|
||||
|
||||
# Remove GROUND and LOCAL from local_flights status enum
|
||||
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','LANDED','CANCELLED')")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Add movements table
|
||||
|
||||
Revision ID: 006_movements
|
||||
Revises: 005_flight_states
|
||||
Create Date: 2026-04-03 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Enum
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '006_movements'
|
||||
down_revision = '005_flight_states'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create movements table
|
||||
op.create_table('movements',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('movement_type', sa.Enum('TAKEOFF', 'LANDING', 'OVERFLIGHT', 'GO_AROUND', 'TOUCH_AND_GO', name='movementtype'), nullable=False),
|
||||
sa.Column('aircraft_registration', sa.String(length=16), nullable=False),
|
||||
sa.Column('aircraft_type', sa.String(length=32), nullable=True),
|
||||
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('entity_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('to_location', sa.String(length=64), nullable=True),
|
||||
sa.Column('from_location', sa.String(length=64), nullable=True),
|
||||
sa.Column('runway', sa.String(length=10), nullable=True),
|
||||
sa.Column('wind', sa.String(length=20), nullable=True),
|
||||
sa.Column('pressure_setting', sa.String(length=20), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=16), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('notes', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('idx_movement_lookup', 'movements', ['entity_type', 'entity_id'], unique=False)
|
||||
op.create_index('idx_movement_time', 'movements', ['timestamp', 'movement_type'], unique=False)
|
||||
op.create_index('ix_movements_movement_type', 'movements', ['movement_type'], unique=False)
|
||||
op.create_index('ix_movements_aircraft_registration', 'movements', ['aircraft_registration'], unique=False)
|
||||
op.create_index('ix_movements_timestamp', 'movements', ['timestamp'], unique=False)
|
||||
op.create_index('ix_movements_entity_type', 'movements', ['entity_type'], unique=False)
|
||||
op.create_index('ix_movements_created_by', 'movements', ['created_by'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index('ix_movements_created_by', table_name='movements')
|
||||
op.drop_index('ix_movements_entity_type', table_name='movements')
|
||||
op.drop_index('ix_movements_timestamp', table_name='movements')
|
||||
op.drop_index('ix_movements_aircraft_registration', table_name='movements')
|
||||
op.drop_index('ix_movements_movement_type', table_name='movements')
|
||||
op.drop_index('idx_movement_time', table_name='movements')
|
||||
op.drop_index('idx_movement_lookup', table_name='movements')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('movements')
|
||||
|
||||
# Drop enum
|
||||
op.execute("DROP TYPE IF EXISTS movementtype")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Add ACTIVATED status to PPR, PENDING status to departures, arrival_id FK on departures
|
||||
|
||||
Revision ID: 007_ppr_activated_status
|
||||
Revises: 006_movements
|
||||
Create Date: 2026-04-03 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '007_ppr_activated_status'
|
||||
down_revision = '006_movements'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add ACTIVATED to PPR status enum
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED','ACTIVATED') NOT NULL"
|
||||
)
|
||||
|
||||
# Add PENDING to departures status enum
|
||||
op.execute(
|
||||
"ALTER TABLE departures MODIFY COLUMN status "
|
||||
"ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CANCELLED','PENDING') NOT NULL"
|
||||
)
|
||||
|
||||
# Add arrival_id FK column to departures (nullable - only set for PPR-activated departures)
|
||||
op.add_column('departures', sa.Column('arrival_id', sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_departures_arrival_id', 'departures', 'arrivals',
|
||||
['arrival_id'], ['id'], ondelete='SET NULL'
|
||||
)
|
||||
op.create_index('idx_departures_arrival_id', 'departures', ['arrival_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_departures_arrival_id', table_name='departures')
|
||||
op.drop_constraint('fk_departures_arrival_id', 'departures', type_='foreignkey')
|
||||
op.drop_column('departures', 'arrival_id')
|
||||
|
||||
op.execute(
|
||||
"UPDATE departures SET status = 'CANCELLED' WHERE status = 'PENDING'"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE departures MODIFY COLUMN status "
|
||||
"ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CANCELLED') NOT NULL"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"UPDATE submitted SET status = 'CONFIRMED' WHERE status = 'ACTIVATED'"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add PPR paper strip acknowledgement fields
|
||||
|
||||
Revision ID: 008_ppr_strip_acknowledgement
|
||||
Revises: 007_ppr_activated_status
|
||||
Create Date: 2026-06-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '008_ppr_strip_acknowledgement'
|
||||
down_revision = '007_ppr_activated_status'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True))
|
||||
op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True))
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE local_flights SET pob = 1 WHERE pob IS NULL")
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_column('submitted', 'acknowledged_by')
|
||||
op.drop_column('submitted', 'acknowledged_dt')
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add drone flight requests
|
||||
|
||||
Revision ID: 009_drone_requests
|
||||
Revises: 008_ppr_strip_acknowledgement
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '009_drone_requests'
|
||||
down_revision = '008_ppr_strip_acknowledgement'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
drone_status = sa.Enum(
|
||||
'NEW',
|
||||
'APPROVED',
|
||||
'DENIED',
|
||||
'PENDING',
|
||||
'CANCELED',
|
||||
'INFLIGHT',
|
||||
'COMPLETED',
|
||||
name='dronerequeststatus',
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'drone_requests',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('reference_number', sa.String(length=24), nullable=False),
|
||||
sa.Column('public_token', sa.String(length=128), nullable=True),
|
||||
sa.Column('status', drone_status, nullable=False),
|
||||
sa.Column('operator_name', sa.String(length=128), nullable=False),
|
||||
sa.Column('operator_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('flyer_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('flyer_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('email', sa.String(length=128), nullable=False),
|
||||
sa.Column('phone', sa.String(length=32), nullable=True),
|
||||
sa.Column('flight_date', sa.Date(), nullable=True),
|
||||
sa.Column('estimated_takeoff_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_completion_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_takeoff_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('estimated_completion_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('maximum_elevation_ft_amsl', sa.Integer(), nullable=False),
|
||||
sa.Column('location_description', sa.Text(), nullable=True),
|
||||
sa.Column('location_latitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_longitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_inside_frz', sa.Boolean(), nullable=True),
|
||||
sa.Column('prototype_overlay', sa.JSON(), nullable=True),
|
||||
sa.Column('applicant_notes', sa.Text(), nullable=True),
|
||||
sa.Column('operator_comments', sa.Text(), nullable=True),
|
||||
sa.Column('submitted_via', sa.String(length=32), nullable=False, server_default='PUBLIC'),
|
||||
sa.Column('submitted_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=50), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('status_changed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status_changed_by', sa.String(length=50), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('reference_number'),
|
||||
)
|
||||
op.create_index(op.f('ix_drone_requests_created_by'), 'drone_requests', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_email'), 'drone_requests', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_completion_at'), 'drone_requests', ['estimated_completion_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_takeoff_at'), 'drone_requests', ['estimated_takeoff_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_flight_date'), 'drone_requests', ['flight_date'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_operator_name'), 'drone_requests', ['operator_name'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_public_token'), 'drone_requests', ['public_token'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_reference_number'), 'drone_requests', ['reference_number'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_status'), 'drone_requests', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_submitted_at'), 'drone_requests', ['submitted_at'], unique=False)
|
||||
op.create_index('idx_drone_status_takeoff', 'drone_requests', ['status', 'estimated_takeoff_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_drone_status_takeoff', table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_submitted_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_status'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_reference_number'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_public_token'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_operator_name'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_flight_date'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_takeoff_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_completion_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_email'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_created_by'), table_name='drone_requests')
|
||||
op.drop_table('drone_requests')
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -11,6 +11,9 @@ 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(movements.router, prefix="/movements", tags=["movements"])
|
||||
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.api.deps import get_db, get_current_active_user
|
||||
from app.models.ppr import Aircraft
|
||||
from app.schemas.ppr import Aircraft as AircraftSchema
|
||||
from app.models.ppr import Aircraft, UserAircraft
|
||||
from app.schemas.ppr import Aircraft as AircraftSchema, UserAircraftCreate, UserAircraft as UserAircraftSchema
|
||||
from app.models.ppr import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,6 +19,7 @@ async def lookup_aircraft_by_registration(
|
||||
"""
|
||||
Lookup aircraft by registration (clean match).
|
||||
Removes non-alphanumeric characters from input for matching.
|
||||
Checks user_aircraft table first, then aircraft table.
|
||||
"""
|
||||
# Clean the input registration (remove non-alphanumeric characters)
|
||||
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
|
||||
@@ -25,7 +27,29 @@ async def lookup_aircraft_by_registration(
|
||||
if len(clean_input) < 4:
|
||||
return []
|
||||
|
||||
# Query aircraft table using clean_reg column
|
||||
# First check user_aircraft table
|
||||
user_aircraft = db.query(UserAircraft).filter(
|
||||
UserAircraft.clean_reg.like(f"{clean_input}%")
|
||||
).limit(10).all()
|
||||
|
||||
if user_aircraft:
|
||||
# Convert UserAircraft to Aircraft-like objects
|
||||
result = []
|
||||
for ua in user_aircraft:
|
||||
# Create a mock Aircraft object with the user data
|
||||
result.append({
|
||||
'id': ua.id,
|
||||
'registration': ua.registration,
|
||||
'type_code': ua.type_code,
|
||||
'clean_reg': ua.clean_reg,
|
||||
'icao24': None,
|
||||
'manufacturer_icao': None,
|
||||
'manufacturer_name': None,
|
||||
'model': None
|
||||
})
|
||||
return result
|
||||
|
||||
# If no user aircraft found, check main aircraft table
|
||||
aircraft_list = db.query(Aircraft).filter(
|
||||
Aircraft.clean_reg.like(f"{clean_input}%")
|
||||
).limit(10).all()
|
||||
@@ -42,6 +66,7 @@ async def public_lookup_aircraft_by_registration(
|
||||
Public lookup aircraft by registration (clean match).
|
||||
Removes non-alphanumeric characters from input for matching.
|
||||
No authentication required.
|
||||
Checks user_aircraft table first, then aircraft table.
|
||||
"""
|
||||
# Clean the input registration (remove non-alphanumeric characters)
|
||||
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
|
||||
@@ -49,7 +74,28 @@ async def public_lookup_aircraft_by_registration(
|
||||
if len(clean_input) < 4:
|
||||
return []
|
||||
|
||||
# Query aircraft table using clean_reg column
|
||||
# First check user_aircraft table
|
||||
user_aircraft = db.query(UserAircraft).filter(
|
||||
UserAircraft.clean_reg.like(f"{clean_input}%")
|
||||
).limit(10).all()
|
||||
|
||||
if user_aircraft:
|
||||
# Convert UserAircraft to Aircraft-like objects
|
||||
result = []
|
||||
for ua in user_aircraft:
|
||||
result.append({
|
||||
'id': ua.id,
|
||||
'registration': ua.registration,
|
||||
'type_code': ua.type_code,
|
||||
'clean_reg': ua.clean_reg,
|
||||
'icao24': None,
|
||||
'manufacturer_icao': None,
|
||||
'manufacturer_name': None,
|
||||
'model': None
|
||||
})
|
||||
return result
|
||||
|
||||
# If no user aircraft found, check main aircraft table
|
||||
aircraft_list = db.query(Aircraft).filter(
|
||||
Aircraft.clean_reg.like(f"{clean_input}%")
|
||||
).limit(10).all()
|
||||
@@ -82,3 +128,100 @@ async def search_aircraft(
|
||||
).limit(limit).all()
|
||||
|
||||
return aircraft_list
|
||||
|
||||
|
||||
@router.post("/user-aircraft", response_model=dict)
|
||||
async def save_user_aircraft(
|
||||
aircraft: UserAircraftCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Save a user-defined aircraft type for a registration.
|
||||
"""
|
||||
# Clean the registration
|
||||
clean_reg = ''.join(c for c in aircraft.registration if c.isalnum()).upper()
|
||||
|
||||
# Check if already exists
|
||||
existing = db.query(UserAircraft).filter(
|
||||
UserAircraft.registration == aircraft.registration.upper()
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Aircraft registration already exists in user database")
|
||||
|
||||
# Create new user aircraft
|
||||
user_aircraft = UserAircraft(
|
||||
registration=aircraft.registration.upper(),
|
||||
type_code=aircraft.type_code.upper(),
|
||||
clean_reg=clean_reg,
|
||||
created_by=current_user.username
|
||||
)
|
||||
|
||||
db.add(user_aircraft)
|
||||
db.commit()
|
||||
db.refresh(user_aircraft)
|
||||
|
||||
return {"message": "Aircraft saved successfully", "id": user_aircraft.id}
|
||||
|
||||
|
||||
@router.get("/user-aircraft", response_model=List[UserAircraftSchema])
|
||||
async def get_user_aircraft(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Get all user-defined aircraft types.
|
||||
"""
|
||||
user_aircraft = db.query(UserAircraft).order_by(UserAircraft.created_at.desc()).all()
|
||||
return user_aircraft
|
||||
|
||||
|
||||
@router.put("/user-aircraft/{registration}", response_model=dict)
|
||||
async def update_user_aircraft(
|
||||
registration: str,
|
||||
aircraft: UserAircraftCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Update a user-defined aircraft type.
|
||||
"""
|
||||
# Find the existing user aircraft
|
||||
existing = db.query(UserAircraft).filter(
|
||||
UserAircraft.registration == registration.upper()
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="User aircraft not found")
|
||||
|
||||
# Update the type
|
||||
existing.type_code = aircraft.type_code.upper()
|
||||
existing.updated_at = func.current_timestamp()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Aircraft updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/user-aircraft/{registration}", response_model=dict)
|
||||
async def delete_user_aircraft(
|
||||
registration: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Delete a user-defined aircraft type.
|
||||
"""
|
||||
# Find the existing user aircraft
|
||||
existing = db.query(UserAircraft).filter(
|
||||
UserAircraft.registration == registration.upper()
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="User aircraft not found")
|
||||
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Aircraft deleted successfully"}
|
||||
@@ -38,12 +38,12 @@ async def create_arrival(
|
||||
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)
|
||||
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username, submitted_via=arrival_in.submitted_via)
|
||||
|
||||
# Send real-time update via WebSocket
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "arrival_booked_in",
|
||||
"type": "arrival_inbound",
|
||||
"data": {
|
||||
"id": arrival.id,
|
||||
"registration": arrival.registration,
|
||||
@@ -159,7 +159,8 @@ async def cancel_arrival(
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Cancel an arrival record"""
|
||||
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
|
||||
client_ip = get_client_ip(request)
|
||||
arrival = crud_arrival.cancel(db, arrival_id=arrival_id, user=current_user.username, user_ip=client_ip)
|
||||
if not arrival:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -87,7 +87,7 @@ async def create_user(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered"
|
||||
)
|
||||
user = crud_user.create(db, obj_in=user_in)
|
||||
user = crud_user.create(db, obj_in=user_in, admin_user=current_user.username)
|
||||
return user
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ async def update_user(
|
||||
status_code=status.HTTP_404_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, admin_user=current_user.username)
|
||||
return user
|
||||
|
||||
|
||||
@@ -123,5 +123,5 @@ async def change_user_password(
|
||||
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)
|
||||
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password, admin_user=current_user.username)
|
||||
return user
|
||||
@@ -33,6 +33,17 @@ async def get_circuits_by_flight(
|
||||
return circuits
|
||||
|
||||
|
||||
@router.get("/arrival/{arrival_id}", response_model=List[Circuit])
|
||||
async def get_circuits_by_arrival(
|
||||
arrival_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user)
|
||||
):
|
||||
"""Get all circuits for a specific arrival"""
|
||||
circuits = crud_circuit.get_by_arrival(db, arrival_id=arrival_id)
|
||||
return circuits
|
||||
|
||||
|
||||
@router.post("/", response_model=Circuit)
|
||||
async def create_circuit(
|
||||
request: Request,
|
||||
@@ -40,8 +51,21 @@ async def create_circuit(
|
||||
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)
|
||||
"""Record a new circuit (touch and go) for a local flight or arrival"""
|
||||
# Validate that exactly one of local_flight_id or arrival_id is provided
|
||||
if not circuit_in.local_flight_id and not circuit_in.arrival_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Either local_flight_id or arrival_id must be provided"
|
||||
)
|
||||
if circuit_in.local_flight_id and circuit_in.arrival_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot provide both local_flight_id and arrival_id"
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
circuit = crud_circuit.create(db, obj_in=circuit_in, user=current_user.username, user_ip=client_ip)
|
||||
|
||||
# Send real-time update via WebSocket
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
@@ -93,6 +117,7 @@ async def update_circuit(
|
||||
|
||||
@router.delete("/{circuit_id}")
|
||||
async def delete_circuit(
|
||||
request: Request,
|
||||
circuit_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
@@ -104,5 +129,6 @@ async def delete_circuit(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Circuit record not found"
|
||||
)
|
||||
crud_circuit.delete(db, circuit_id=circuit_id)
|
||||
client_ip = get_client_ip(request)
|
||||
crud_circuit.delete(db, circuit_id=circuit_id, user=current_user.username, user_ip=client_ip)
|
||||
return {"detail": "Circuit record deleted"}
|
||||
|
||||
@@ -38,7 +38,7 @@ async def create_departure(
|
||||
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)
|
||||
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username, submitted_via="ADMIN")
|
||||
|
||||
# Send real-time update via WebSocket
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
@@ -159,7 +159,8 @@ async def cancel_departure(
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Cancel a departure record"""
|
||||
departure = crud_departure.cancel(db, departure_id=departure_id)
|
||||
client_ip = get_client_ip(request)
|
||||
departure = crud_departure.cancel(db, departure_id=departure_id, user=current_user.username, user_ip=client_ip)
|
||||
if not departure:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_operator_user, get_current_read_user, get_db
|
||||
from app.core.email import email_service
|
||||
from app.core.config import settings
|
||||
from app.core.frz import swansea_frz_geojson
|
||||
from app.core.utils import get_client_ip
|
||||
from app.crud.crud_drone_request import drone_request as crud_drone_request
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.journal import EntityType
|
||||
from app.models.ppr import User
|
||||
from app.schemas.drone_request import (
|
||||
DroneRequest,
|
||||
DroneRequestComment,
|
||||
DroneRequestCreate,
|
||||
DroneRequestStatus,
|
||||
DroneRequestStatusUpdate,
|
||||
DroneRequestUpdate,
|
||||
)
|
||||
from app.schemas.journal import JournalEntryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _broadcast(request: Request, event_type: str, drone_request: DroneRequest):
|
||||
if hasattr(request.app.state, "connection_manager"):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": event_type,
|
||||
"data": {
|
||||
"id": drone_request.id,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
async def _send_drone_email(drone_request, subject: str, message: str):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=subject,
|
||||
template_name="drone_request_update.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"message": message,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_submitted_email(drone_request):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=f"Drone flight request received {drone_request.reference_number}",
|
||||
template_name="drone_request_submitted.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=f"Drone request {drone_request.reference_number} APPROVED",
|
||||
template_name="drone_request_approved.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"message": message,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DroneRequest])
|
||||
async def get_drone_requests(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
return crud_drone_request.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/public", response_model=DroneRequest)
|
||||
async def create_public_drone_request(
|
||||
request: Request,
|
||||
drone_request_in: DroneRequestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.create(
|
||||
db,
|
||||
obj_in=drone_request_in,
|
||||
created_by="public",
|
||||
user_ip=client_ip,
|
||||
submitted_via="PUBLIC",
|
||||
)
|
||||
|
||||
await _broadcast(request, "drone_request_created", drone_request)
|
||||
await _send_drone_submitted_email(drone_request)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.get("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def get_drone_request_for_edit(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def update_drone_request_public(
|
||||
token: str,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
updated_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=drone_request,
|
||||
obj_in=drone_request_in,
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", updated_request)
|
||||
return updated_request
|
||||
|
||||
|
||||
@router.delete("/public/cancel/{token}", response_model=DroneRequest)
|
||||
async def cancel_drone_request_public(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
cancelled_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=drone_request.id,
|
||||
status=DroneRequestStatus.CANCELED,
|
||||
comment="Cancelled by operator using secure link",
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_status_update", cancelled_request)
|
||||
await _send_drone_email(
|
||||
cancelled_request,
|
||||
f"Drone request {cancelled_request.reference_number} CANCELED",
|
||||
"Your drone flight request has been cancelled.",
|
||||
)
|
||||
return cancelled_request
|
||||
|
||||
|
||||
@router.get("/frz")
|
||||
async def get_swansea_drone_frz():
|
||||
return swansea_frz_geojson()
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=DroneRequest)
|
||||
async def get_drone_request(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}", response_model=DroneRequest)
|
||||
async def update_drone_request(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
db_request = crud_drone_request.get(db, request_id)
|
||||
if not db_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=db_request,
|
||||
obj_in=drone_request_in,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", drone_request)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}/status", response_model=DroneRequest)
|
||||
async def update_drone_request_status(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
status_update: DroneRequestStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=request_id,
|
||||
status=status_update.status,
|
||||
comment=status_update.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_status_update", drone_request)
|
||||
message = status_update.comment or f"Your drone flight request status is now {drone_request.status.value}."
|
||||
if drone_request.status == DroneRequestStatus.APPROVED:
|
||||
await _send_drone_approved_email(drone_request, status_update.comment)
|
||||
else:
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} {drone_request.status.value}",
|
||||
message,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.post("/{request_id}/comments", response_model=DroneRequest)
|
||||
async def add_drone_request_comment(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
comment_in: DroneRequestComment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.add_comment(
|
||||
db,
|
||||
request_id=request_id,
|
||||
comment=comment_in.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_comment_added", drone_request)
|
||||
if comment_in.email_applicant:
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} update",
|
||||
comment_in.comment,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.get("/{request_id}/journal", response_model=List[JournalEntryResponse])
|
||||
async def get_drone_request_journal(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return crud_journal.get_entity_journal(db, EntityType.DRONE_REQUEST, request_id)
|
||||
@@ -4,11 +4,79 @@ 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
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
|
||||
router = APIRouter(tags=["journal"])
|
||||
|
||||
|
||||
@router.get("/search/all", response_model=List[JournalEntryResponse])
|
||||
async def search_journal(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[int] = None,
|
||||
user: Optional[str] = None,
|
||||
limit: int = 500,
|
||||
db: Session = Depends(deps.get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""
|
||||
Search journal entries with optional filters.
|
||||
|
||||
Parameters:
|
||||
- date_from: Filter entries from this date (YYYY-MM-DD)
|
||||
- date_to: Filter entries until this date (YYYY-MM-DD)
|
||||
- entity_type: Filter by entity type (PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT, CIRCUIT, USER)
|
||||
- entity_id: Filter by specific entity ID
|
||||
- user: Filter by user who created the entry
|
||||
- limit: Maximum number of entries to return (default 500, max 5000)
|
||||
|
||||
All filters are optional and can be combined.
|
||||
Returns entries in reverse chronological order (newest first).
|
||||
"""
|
||||
if limit > 5000:
|
||||
limit = 5000
|
||||
|
||||
# Validate entity_type if provided
|
||||
if entity_type:
|
||||
try:
|
||||
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.search_entries(
|
||||
db,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
user=user,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return 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
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
|
||||
async def get_entity_journal(
|
||||
entity_type: str,
|
||||
@@ -45,19 +113,3 @@ async def get_entity_journal(
|
||||
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
|
||||
|
||||
@@ -39,7 +39,7 @@ async def create_local_flight(
|
||||
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)
|
||||
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username, submitted_via="ADMIN")
|
||||
|
||||
# Send real-time update via WebSocket
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
@@ -160,7 +160,8 @@ async def cancel_local_flight(
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Cancel a local flight record"""
|
||||
flight = crud_local_flight.cancel(db, flight_id=flight_id)
|
||||
client_ip = get_client_ip(request)
|
||||
flight = crud_local_flight.cancel(db, flight_id=flight_id, user=current_user.username, user_ip=client_ip)
|
||||
if not flight:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from datetime import date, datetime, time
|
||||
from app.api.deps import get_db, get_current_operator_user, get_current_read_user
|
||||
from app.crud.crud_movement import movement as crud_movement
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.schemas.movement import BulkMovementContext, BulkMovementLog, BulkMovementResult, Movement, MovementCreate
|
||||
from app.models.ppr import User
|
||||
from app.models.arrival import Arrival, ArrivalStatus, SubmissionSource as ArrivalSubmissionSource
|
||||
from app.models.circuit import Circuit
|
||||
from app.models.departure import Departure, DepartureStatus, SubmissionSource as DepartureSubmissionSource
|
||||
from app.models.journal import EntityType
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType, SubmissionSource as LocalSubmissionSource
|
||||
from app.models.movement import Movement as MovementModel, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
from app.core.utils import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Movement])
|
||||
async def get_movements(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
movement_type: Optional[MovementType] = None,
|
||||
aircraft_registration: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user)
|
||||
):
|
||||
"""Get movement records with optional filtering"""
|
||||
movements = crud_movement.get_multi(
|
||||
db, skip=skip, limit=limit, movement_type=movement_type,
|
||||
aircraft_registration=aircraft_registration, date_from=date_from,
|
||||
date_to=date_to, entity_type=entity_type
|
||||
)
|
||||
return movements
|
||||
|
||||
|
||||
def _clean_reg(registration: str) -> str:
|
||||
return (registration or "").strip().upper()
|
||||
|
||||
|
||||
def _clean_alnum(value: str) -> str:
|
||||
return "".join(char for char in (value or "").upper() if char.isalnum())
|
||||
|
||||
|
||||
def _sql_clean_alnum(column):
|
||||
return func.upper(func.replace(func.replace(column, "-", ""), " ", ""))
|
||||
|
||||
|
||||
def _combine_date_time(movement_date: date, movement_time: str) -> datetime:
|
||||
if not movement_time:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="A time is required for this strip"
|
||||
)
|
||||
try:
|
||||
parsed_time = time.fromisoformat(movement_time)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="movement_time must be HH:MM or HH:MM:SS"
|
||||
)
|
||||
return datetime.combine(movement_date, parsed_time)
|
||||
|
||||
|
||||
def _kind_to_movement_type(flight_kind: str) -> MovementType:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
if kind == "ARRIVAL":
|
||||
return MovementType.LANDING
|
||||
if kind == "DEPARTURE":
|
||||
return MovementType.TAKEOFF
|
||||
if kind == "LOCAL":
|
||||
return MovementType.TAKEOFF
|
||||
if kind == "OVERFLIGHT":
|
||||
return MovementType.OVERFLIGHT
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="flight_kind must be ARRIVAL, DEPARTURE, LOCAL or OVERFLIGHT"
|
||||
)
|
||||
|
||||
|
||||
def _strip_entity_type(flight_kind: str) -> Optional[str]:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
return {
|
||||
"LOCAL": "LOCAL_FLIGHT",
|
||||
"OVERFLIGHT": "OVERFLIGHT",
|
||||
}.get(kind)
|
||||
|
||||
|
||||
def _compatible_entity_types(flight_kind: str) -> List[str]:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
if kind == "ARRIVAL":
|
||||
return ["PPR", "ARRIVAL"]
|
||||
if kind == "DEPARTURE":
|
||||
return ["PPR", "DEPARTURE"]
|
||||
if kind == "LOCAL":
|
||||
return ["LOCAL_FLIGHT"]
|
||||
if kind == "OVERFLIGHT":
|
||||
return ["OVERFLIGHT"]
|
||||
return []
|
||||
|
||||
|
||||
def _ppr_to_dict(ppr: PPRRecord) -> dict:
|
||||
return {
|
||||
"id": ppr.id,
|
||||
"status": ppr.status.value,
|
||||
"aircraft_registration": ppr.ac_reg,
|
||||
"aircraft_type": ppr.ac_type,
|
||||
"callsign": ppr.ac_call,
|
||||
"from_location": ppr.in_from,
|
||||
"to_location": ppr.out_to,
|
||||
"eta": ppr.eta.isoformat() if ppr.eta else None,
|
||||
"etd": ppr.etd.isoformat() if ppr.etd else None,
|
||||
"pob_in": ppr.pob_in,
|
||||
"pob_out": ppr.pob_out,
|
||||
"notes": ppr.notes,
|
||||
}
|
||||
|
||||
|
||||
def _movement_to_dict(movement: MovementModel) -> dict:
|
||||
return {
|
||||
"id": movement.id,
|
||||
"movement_type": movement.movement_type.value,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"aircraft_type": movement.aircraft_type,
|
||||
"callsign": movement.callsign,
|
||||
"timestamp": movement.timestamp.isoformat() if movement.timestamp else None,
|
||||
"entity_type": movement.entity_type,
|
||||
"entity_id": movement.entity_id,
|
||||
"from_location": movement.from_location,
|
||||
"to_location": movement.to_location,
|
||||
"runway": movement.runway,
|
||||
"wind": movement.wind,
|
||||
"pressure_setting": movement.pressure_setting,
|
||||
"notes": movement.notes,
|
||||
}
|
||||
|
||||
|
||||
def _local_flight_to_dict(local: LocalFlight) -> dict:
|
||||
return {
|
||||
"id": local.id,
|
||||
"aircraft_registration": local.registration,
|
||||
"aircraft_type": local.type,
|
||||
"callsign": local.callsign,
|
||||
"pob": local.pob,
|
||||
"flight_type": local.flight_type.value if local.flight_type else None,
|
||||
"status": local.status.value if local.status else None,
|
||||
"etd": local.etd.isoformat() if local.etd else None,
|
||||
"takeoff_time": local.takeoff_dt.isoformat() if local.takeoff_dt else None,
|
||||
"departed_time": local.departed_dt.isoformat() if local.departed_dt else None,
|
||||
"landing_time": local.landed_dt.isoformat() if local.landed_dt else None,
|
||||
"circuits": local.circuits,
|
||||
"notes": local.notes,
|
||||
}
|
||||
|
||||
|
||||
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
|
||||
if movements:
|
||||
movement = movements[0]
|
||||
return {
|
||||
"source": "movement",
|
||||
"movement_id": movement.id,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"aircraft_type": movement.aircraft_type,
|
||||
"callsign": movement.callsign,
|
||||
"movement_time": movement.timestamp.strftime("%H:%M") if movement.timestamp else None,
|
||||
"from_location": movement.from_location,
|
||||
"to_location": movement.to_location,
|
||||
"runway": movement.runway,
|
||||
"wind": movement.wind,
|
||||
"pressure_setting": movement.pressure_setting,
|
||||
"notes": movement.notes,
|
||||
}
|
||||
|
||||
if pprs:
|
||||
ppr = pprs[0]
|
||||
is_arrival = flight_kind.upper() == "ARRIVAL"
|
||||
timestamp = ppr.eta if is_arrival else ppr.etd
|
||||
return {
|
||||
"source": "ppr",
|
||||
"ppr_id": ppr.id,
|
||||
"aircraft_registration": ppr.ac_reg,
|
||||
"aircraft_type": ppr.ac_type,
|
||||
"callsign": ppr.ac_call,
|
||||
"movement_time": timestamp.strftime("%H:%M") if timestamp else None,
|
||||
"from_location": ppr.in_from,
|
||||
"to_location": ppr.out_to,
|
||||
"pob": ppr.pob_in if is_arrival else (ppr.pob_out or ppr.pob_in),
|
||||
"notes": ppr.notes,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _movement_for_entity(
|
||||
db: Session,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
movement_type: MovementType
|
||||
) -> Optional[MovementModel]:
|
||||
return db.query(MovementModel).filter(
|
||||
MovementModel.entity_type == entity_type,
|
||||
MovementModel.entity_id == entity_id,
|
||||
MovementModel.movement_type == movement_type
|
||||
).order_by(MovementModel.timestamp).first()
|
||||
|
||||
|
||||
def _create_or_update_movement(db: Session, movement_data: MovementCreate) -> MovementModel:
|
||||
existing = _movement_for_entity(
|
||||
db,
|
||||
movement_data.entity_type,
|
||||
movement_data.entity_id,
|
||||
movement_data.movement_type
|
||||
)
|
||||
if existing:
|
||||
return crud_movement.update(db, existing, movement_data)
|
||||
return crud_movement.create(db, movement_data)
|
||||
|
||||
|
||||
def _clear_local_circuit_details(
|
||||
db: Session,
|
||||
local_flight: LocalFlight
|
||||
) -> None:
|
||||
db.query(Circuit).filter(Circuit.local_flight_id == local_flight.id).delete()
|
||||
db.query(MovementModel).filter(
|
||||
MovementModel.entity_type == "LOCAL_FLIGHT",
|
||||
MovementModel.entity_id == local_flight.id,
|
||||
MovementModel.movement_type == MovementType.TOUCH_AND_GO
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/bulk-context", response_model=BulkMovementContext)
|
||||
async def get_bulk_movement_context(
|
||||
target_date: date,
|
||||
aircraft_registration: str,
|
||||
flight_kind: str = "ARRIVAL",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user)
|
||||
):
|
||||
"""Find same-day PPRs and movements that may match a bulk entry strip."""
|
||||
clean_reg = _clean_reg(aircraft_registration)
|
||||
clean_lookup = _clean_alnum(aircraft_registration)
|
||||
movement_type = _kind_to_movement_type(flight_kind)
|
||||
entity_type_filter = _strip_entity_type(flight_kind)
|
||||
|
||||
pprs = []
|
||||
local_flights = []
|
||||
if clean_lookup and flight_kind.upper() != "LOCAL":
|
||||
pprs = db.query(PPRRecord).filter(
|
||||
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
func.date(PPRRecord.eta) == target_date,
|
||||
func.date(PPRRecord.etd) == target_date
|
||||
),
|
||||
PPRRecord.status != PPRStatus.DELETED
|
||||
).order_by(PPRRecord.eta).limit(10).all()
|
||||
|
||||
if clean_lookup and flight_kind.upper() == "LOCAL":
|
||||
local_flights = db.query(LocalFlight).filter(
|
||||
_sql_clean_alnum(LocalFlight.registration).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
func.date(LocalFlight.takeoff_dt) == target_date,
|
||||
func.date(LocalFlight.departed_dt) == target_date,
|
||||
func.date(LocalFlight.landed_dt) == target_date,
|
||||
func.date(LocalFlight.etd) == target_date,
|
||||
func.date(LocalFlight.created_dt) == target_date
|
||||
),
|
||||
LocalFlight.flight_type.in_([LocalFlightType.LOCAL, LocalFlightType.CIRCUITS]),
|
||||
LocalFlight.status != LocalFlightStatus.CANCELLED
|
||||
).order_by(LocalFlight.takeoff_dt, LocalFlight.etd, LocalFlight.created_dt).limit(10).all()
|
||||
|
||||
movements = []
|
||||
if clean_lookup:
|
||||
movements = db.query(MovementModel).filter(
|
||||
func.date(MovementModel.timestamp) == target_date,
|
||||
_sql_clean_alnum(MovementModel.aircraft_registration).like(f"{clean_lookup}%"),
|
||||
MovementModel.movement_type == movement_type
|
||||
).order_by(MovementModel.timestamp.desc()).limit(10).all()
|
||||
compatible_types = _compatible_entity_types(flight_kind)
|
||||
if compatible_types:
|
||||
movements = [movement for movement in movements if movement.entity_type in compatible_types]
|
||||
|
||||
return BulkMovementContext(
|
||||
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
|
||||
local_flights=[_local_flight_to_dict(local) for local in local_flights],
|
||||
movements=[_movement_to_dict(movement) for movement in movements],
|
||||
suggested=_build_suggestion(pprs, movements, flight_kind)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-log", response_model=BulkMovementResult)
|
||||
async def bulk_log_movement(
|
||||
request: Request,
|
||||
entry: BulkMovementLog,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Create or update one same-day movement from a paper strip."""
|
||||
client_ip = get_client_ip(request)
|
||||
username = current_user.username
|
||||
clean_reg = _clean_reg(entry.aircraft_registration)
|
||||
clean_lookup = _clean_alnum(entry.aircraft_registration)
|
||||
if not clean_reg:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aircraft registration is required")
|
||||
|
||||
movement_type = _kind_to_movement_type(entry.flight_kind)
|
||||
flight_kind = entry.flight_kind.strip().upper()
|
||||
primary_time = (
|
||||
entry.landing_time if flight_kind == "ARRIVAL"
|
||||
else entry.takeoff_time if flight_kind in ("DEPARTURE", "LOCAL")
|
||||
else entry.contact_time if flight_kind == "OVERFLIGHT"
|
||||
else entry.movement_time
|
||||
) or entry.movement_time
|
||||
timestamp = _combine_date_time(entry.movement_date, primary_time)
|
||||
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
|
||||
if not existing_movement and flight_kind != "LOCAL":
|
||||
existing_movement = crud_movement.find_daily_match(
|
||||
db,
|
||||
entry.movement_date,
|
||||
clean_reg,
|
||||
movement_type,
|
||||
entity_type=_strip_entity_type(flight_kind),
|
||||
entity_types=None if _strip_entity_type(flight_kind) else _compatible_entity_types(flight_kind)
|
||||
)
|
||||
if existing_movement and existing_movement.entity_type not in _compatible_entity_types(flight_kind):
|
||||
existing_movement = None
|
||||
|
||||
if flight_kind == "LOCAL":
|
||||
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
|
||||
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
|
||||
if landing_dt < takeoff_dt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="LOCAL landing time cannot be before takeoff time"
|
||||
)
|
||||
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
|
||||
local = LocalFlight(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type or "",
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob,
|
||||
flight_type=local_type,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
|
||||
circuits=entry.circuits or 0,
|
||||
notes=entry.notes,
|
||||
etd=takeoff_dt,
|
||||
departed_dt=takeoff_dt,
|
||||
takeoff_dt=takeoff_dt,
|
||||
landed_dt=landing_dt,
|
||||
created_by=username,
|
||||
submitted_via=LocalSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(local)
|
||||
db.commit()
|
||||
db.refresh(local)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
|
||||
|
||||
takeoff_movement = _create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.TAKEOFF,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=local.type,
|
||||
callsign=local.callsign,
|
||||
timestamp=takeoff_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=local.id,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
_create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.LANDING,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=local.type,
|
||||
callsign=local.callsign,
|
||||
timestamp=landing_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=local.id,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
_clear_local_circuit_details(db, local)
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, f"Bulk local strip {action}: takeoff {takeoff_dt.strftime('%H:%M')}, landing {landing_dt.strftime('%H:%M')}, circuits {entry.circuits or 0}", username, client_ip)
|
||||
return BulkMovementResult(action=action, movement=takeoff_movement, entity_type="LOCAL_FLIGHT", entity_id=local.id, message=f"Local strip {action} for {clean_reg}")
|
||||
|
||||
if flight_kind == "OVERFLIGHT":
|
||||
contact_dt = _combine_date_time(entry.movement_date, entry.contact_time)
|
||||
qsy_dt = _combine_date_time(entry.movement_date, entry.qsy_time) if entry.qsy_time else None
|
||||
overflight = None
|
||||
if existing_movement and existing_movement.entity_type == "OVERFLIGHT":
|
||||
overflight = db.query(Overflight).filter(Overflight.id == existing_movement.entity_id).first()
|
||||
if not overflight:
|
||||
overflight = db.query(Overflight).filter(
|
||||
_sql_clean_alnum(Overflight.registration) == clean_lookup,
|
||||
func.date(Overflight.call_dt) == entry.movement_date
|
||||
).first()
|
||||
if not overflight:
|
||||
overflight = Overflight(
|
||||
registration=clean_reg,
|
||||
pob=entry.pob,
|
||||
type=entry.aircraft_type,
|
||||
departure_airfield=entry.from_location,
|
||||
destination_airfield=entry.to_location,
|
||||
status=OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE,
|
||||
call_dt=contact_dt,
|
||||
qsy_dt=qsy_dt,
|
||||
notes=entry.notes,
|
||||
created_by=username
|
||||
)
|
||||
db.add(overflight)
|
||||
db.commit()
|
||||
db.refresh(overflight)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, "Overflight strip created from bulk flight log", username, client_ip)
|
||||
else:
|
||||
overflight.registration = clean_reg
|
||||
overflight.pob = entry.pob
|
||||
overflight.type = entry.aircraft_type
|
||||
overflight.departure_airfield = entry.from_location
|
||||
overflight.destination_airfield = entry.to_location
|
||||
overflight.status = OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE
|
||||
overflight.call_dt = contact_dt
|
||||
overflight.qsy_dt = qsy_dt
|
||||
overflight.notes = entry.notes
|
||||
db.add(overflight)
|
||||
db.commit()
|
||||
db.refresh(overflight)
|
||||
action = "updated"
|
||||
|
||||
movement = _create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.OVERFLIGHT,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
timestamp=contact_dt,
|
||||
entity_type="OVERFLIGHT",
|
||||
entity_id=overflight.id,
|
||||
from_location=entry.from_location,
|
||||
to_location=entry.to_location,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, f"Bulk overflight strip {action}: contact {contact_dt.strftime('%H:%M')}" + (f", QSY {qsy_dt.strftime('%H:%M')}" if qsy_dt else ""), username, client_ip)
|
||||
return BulkMovementResult(action=action, movement=movement, entity_type="OVERFLIGHT", entity_id=overflight.id, message=f"Overflight strip {action} for {clean_reg}")
|
||||
|
||||
ppr = None
|
||||
if entry.ppr_id:
|
||||
ppr = db.query(PPRRecord).filter(PPRRecord.id == entry.ppr_id).first()
|
||||
if not ppr and existing_movement and existing_movement.entity_type == "PPR":
|
||||
ppr = db.query(PPRRecord).filter(PPRRecord.id == existing_movement.entity_id).first()
|
||||
if not ppr:
|
||||
ppr = db.query(PPRRecord).filter(
|
||||
_sql_clean_alnum(PPRRecord.ac_reg) == clean_lookup,
|
||||
or_(
|
||||
func.date(PPRRecord.eta) == entry.movement_date,
|
||||
func.date(PPRRecord.etd) == entry.movement_date
|
||||
),
|
||||
PPRRecord.status != PPRStatus.DELETED
|
||||
).order_by(PPRRecord.eta).first()
|
||||
|
||||
entity_type = existing_movement.entity_type if existing_movement else None
|
||||
entity_id = existing_movement.entity_id if existing_movement else None
|
||||
|
||||
if not entity_type:
|
||||
if ppr:
|
||||
entity_type = "PPR"
|
||||
entity_id = ppr.id
|
||||
elif movement_type == MovementType.LANDING:
|
||||
arrival = Arrival(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob or 1,
|
||||
in_from=entry.from_location or "ZZZZ",
|
||||
status=ArrivalStatus.LANDED,
|
||||
notes=entry.notes,
|
||||
eta=timestamp,
|
||||
landed_dt=timestamp,
|
||||
created_by=username,
|
||||
submitted_via=ArrivalSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
entity_type = "ARRIVAL"
|
||||
entity_id = arrival.id
|
||||
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, "Arrival created from bulk flight log", username, client_ip)
|
||||
else:
|
||||
departure = Departure(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob or 1,
|
||||
out_to=entry.to_location or "ZZZZ",
|
||||
status=DepartureStatus.DEPARTED,
|
||||
notes=entry.notes,
|
||||
etd=timestamp,
|
||||
takeoff_dt=timestamp,
|
||||
departed_dt=timestamp,
|
||||
created_by=username,
|
||||
submitted_via=DepartureSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
db.refresh(departure)
|
||||
entity_type = "DEPARTURE"
|
||||
entity_id = departure.id
|
||||
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, "Departure created from bulk flight log", username, client_ip)
|
||||
|
||||
movement_data = MovementCreate(
|
||||
movement_type=movement_type,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
timestamp=timestamp,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
from_location=entry.from_location,
|
||||
to_location=entry.to_location,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
)
|
||||
|
||||
if existing_movement:
|
||||
movement = crud_movement.update(db, existing_movement, movement_data)
|
||||
action = "updated"
|
||||
else:
|
||||
movement = crud_movement.create(db, movement_data)
|
||||
action = "created"
|
||||
|
||||
if entity_type == "PPR" and ppr:
|
||||
ppr.ac_type = entry.aircraft_type or ppr.ac_type
|
||||
ppr.ac_call = entry.callsign or ppr.ac_call
|
||||
if movement_type == MovementType.LANDING:
|
||||
ppr.in_from = entry.from_location or ppr.in_from
|
||||
ppr.pob_in = entry.pob or ppr.pob_in
|
||||
ppr.landed_dt = timestamp
|
||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED, PPRStatus.DEPARTED):
|
||||
ppr.status = PPRStatus.LANDED
|
||||
else:
|
||||
ppr.out_to = entry.to_location or ppr.out_to
|
||||
ppr.pob_out = entry.pob or ppr.pob_out
|
||||
ppr.departed_dt = timestamp
|
||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
|
||||
ppr.status = PPRStatus.DEPARTED
|
||||
if entry.notes:
|
||||
ppr.notes = entry.notes
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
ppr.id,
|
||||
f"Bulk flight log {action}: {movement_type.value} at {timestamp.strftime('%Y-%m-%d %H:%M')}",
|
||||
username,
|
||||
client_ip
|
||||
)
|
||||
elif entity_type == "ARRIVAL":
|
||||
arrival = db.query(Arrival).filter(Arrival.id == entity_id).first()
|
||||
if arrival:
|
||||
arrival.registration = clean_reg
|
||||
arrival.type = entry.aircraft_type
|
||||
arrival.callsign = entry.callsign
|
||||
arrival.pob = entry.pob or arrival.pob
|
||||
arrival.in_from = entry.from_location or arrival.in_from
|
||||
arrival.eta = timestamp
|
||||
arrival.landed_dt = timestamp
|
||||
arrival.status = ArrivalStatus.LANDED
|
||||
arrival.notes = entry.notes
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, f"Bulk flight log {action}: landing at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
|
||||
elif entity_type == "DEPARTURE":
|
||||
departure = db.query(Departure).filter(Departure.id == entity_id).first()
|
||||
if departure:
|
||||
departure.registration = clean_reg
|
||||
departure.type = entry.aircraft_type
|
||||
departure.callsign = entry.callsign
|
||||
departure.pob = entry.pob or departure.pob
|
||||
departure.out_to = entry.to_location or departure.out_to
|
||||
departure.etd = timestamp
|
||||
departure.takeoff_dt = timestamp
|
||||
departure.departed_dt = timestamp
|
||||
departure.status = DepartureStatus.DEPARTED
|
||||
departure.notes = entry.notes
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, f"Bulk flight log {action}: takeoff at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
|
||||
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "movement_bulk_logged",
|
||||
"data": {
|
||||
"id": movement.id,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"movement_type": movement.movement_type.value,
|
||||
"action": action
|
||||
}
|
||||
})
|
||||
|
||||
return BulkMovementResult(
|
||||
action=action,
|
||||
movement=movement,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
message=f"Movement {action} for {clean_reg}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{movement_id}", response_model=Movement)
|
||||
async def get_movement(
|
||||
movement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user)
|
||||
):
|
||||
"""Get a specific movement record"""
|
||||
movement = crud_movement.get(db, movement_id=movement_id)
|
||||
if not movement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Movement record not found"
|
||||
)
|
||||
return movement
|
||||
@@ -5,7 +5,12 @@ from datetime import date
|
||||
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||
from app.crud.crud_ppr import ppr as crud_ppr
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
|
||||
from app.crud.crud_arrival import arrival as crud_arrival
|
||||
from app.crud.crud_departure import departure as crud_departure
|
||||
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate
|
||||
from app.schemas.journal import JournalEntryResponse
|
||||
from app.schemas.arrival import ArrivalCreate
|
||||
from app.schemas.departure import DepartureCreate
|
||||
from app.models.ppr import User
|
||||
from app.core.utils import get_client_ip
|
||||
from app.core.email import email_service
|
||||
@@ -235,6 +240,41 @@ async def update_ppr_status(
|
||||
return ppr
|
||||
|
||||
|
||||
@router.post("/{ppr_id}/acknowledge", response_model=PPR)
|
||||
async def acknowledge_ppr_strip(
|
||||
request: Request,
|
||||
ppr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Acknowledge that the operator has created the paper strip for a PPR."""
|
||||
client_ip = get_client_ip(request)
|
||||
ppr = crud_ppr.acknowledge_strip(
|
||||
db,
|
||||
ppr_id=ppr_id,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip
|
||||
)
|
||||
if not ppr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="PPR record not found"
|
||||
)
|
||||
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "ppr_acknowledged",
|
||||
"data": {
|
||||
"id": ppr.id,
|
||||
"ac_reg": ppr.ac_reg,
|
||||
"acknowledged_dt": ppr.acknowledged_dt.isoformat() if ppr.acknowledged_dt else None,
|
||||
"acknowledged_by": ppr.acknowledged_by
|
||||
}
|
||||
})
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
@router.delete("/{ppr_id}", response_model=PPR)
|
||||
async def delete_ppr(
|
||||
request: Request,
|
||||
@@ -358,7 +398,7 @@ async def cancel_ppr_public(
|
||||
return cancelled_ppr
|
||||
|
||||
|
||||
@router.get("/{ppr_id}/journal", response_model=List[Journal])
|
||||
@router.get("/{ppr_id}/journal", response_model=List[JournalEntryResponse])
|
||||
async def get_ppr_journal(
|
||||
ppr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -373,4 +413,78 @@ async def get_ppr_journal(
|
||||
detail="PPR record not found"
|
||||
)
|
||||
|
||||
return crud_journal.get_by_ppr_id(db, ppr_id=ppr_id)
|
||||
return crud_journal.get_ppr_journal(db, ppr_id=ppr_id)
|
||||
|
||||
|
||||
@router.post("/{ppr_id}/activate")
|
||||
async def activate_ppr(
|
||||
request: Request,
|
||||
ppr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Activate a PPR by creating BOOKED_IN arrival and (if out_to set) BOOKED_OUT departure records."""
|
||||
db_ppr = crud_ppr.get(db, ppr_id=ppr_id)
|
||||
if not db_ppr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="PPR record not found")
|
||||
|
||||
if db_ppr.status not in (PPRStatus.NEW, PPRStatus.CONFIRMED):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"PPR cannot be activated in its current state ({db_ppr.status.value})"
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
username = current_user.username
|
||||
|
||||
# Create INBOUND arrival (ADMIN submitted_via sets status to INBOUND)
|
||||
in_from = (db_ppr.in_from or "ZZZZ")[:4].upper()
|
||||
arrival_in = ArrivalCreate(
|
||||
registration=db_ppr.ac_reg,
|
||||
type=db_ppr.ac_type,
|
||||
callsign=db_ppr.ac_call,
|
||||
pob=db_ppr.pob_in,
|
||||
in_from=in_from,
|
||||
eta=db_ppr.eta,
|
||||
notes=db_ppr.notes,
|
||||
submitted_via="ADMIN"
|
||||
)
|
||||
new_arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=username, submitted_via="ADMIN", user_ip=client_ip)
|
||||
|
||||
# Create PENDING departure linked to this arrival (only visible once arrival lands)
|
||||
new_departure = None
|
||||
if db_ppr.out_to:
|
||||
departure_in = DepartureCreate(
|
||||
registration=db_ppr.ac_reg,
|
||||
type=db_ppr.ac_type,
|
||||
callsign=db_ppr.ac_call,
|
||||
pob=db_ppr.pob_out if db_ppr.pob_out else db_ppr.pob_in,
|
||||
out_to=db_ppr.out_to,
|
||||
etd=db_ppr.etd,
|
||||
notes=db_ppr.notes,
|
||||
arrival_id=new_arrival.id,
|
||||
)
|
||||
new_departure = crud_departure.create(db, obj_in=departure_in, created_by=username, submitted_via="ADMIN", user_ip=client_ip)
|
||||
|
||||
# Mark PPR as ACTIVATED — removes it from Today's PPR and pending arrivals displays
|
||||
crud_ppr.update_status(db, ppr_id=ppr_id, status=PPRStatus.ACTIVATED, user=username, user_ip=client_ip)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "ppr_activated",
|
||||
"data": {
|
||||
"ppr_id": ppr_id,
|
||||
"arrival_id": new_arrival.id,
|
||||
"departure_id": new_departure.id if new_departure else None
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"arrival_id": new_arrival.id,
|
||||
"departure_id": new_departure.id if new_departure else None,
|
||||
"message": (
|
||||
f"PPR activated: arrival #{new_arrival.id} created"
|
||||
+ (f", departure #{new_departure.id} queued (will appear when aircraft lands)" if new_departure else "")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,28 +59,36 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||
'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())
|
||||
|
||||
# Add airborne local flights that were booked out today.
|
||||
# Admin now moves local flights from GROUND to LOCAL/CIRCUIT rather than DEPARTED.
|
||||
airborne_local_statuses = {
|
||||
LocalFlightStatus.DEPARTED,
|
||||
LocalFlightStatus.LOCAL,
|
||||
LocalFlightStatus.CIRCUIT,
|
||||
LocalFlightStatus.CIRCUIT_DOWNWIND,
|
||||
LocalFlightStatus.CIRCUIT_BASE,
|
||||
LocalFlightStatus.CIRCUIT_FINAL,
|
||||
}
|
||||
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||
|
||||
# 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
|
||||
if flight.status not in airborne_local_statuses:
|
||||
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)
|
||||
# Calculate ETA from actual takeoff/departure + duration, falling back to ETD.
|
||||
departure_time = flight.takeoff_dt or flight.departed_dt or flight.etd
|
||||
eta = departure_time
|
||||
if departure_time and flight.duration:
|
||||
eta = departure_time + timedelta(minutes=flight.duration)
|
||||
|
||||
arrivals_list.append({
|
||||
'ac_call': flight.callsign or flight.registration,
|
||||
@@ -89,7 +97,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||
'in_from': None,
|
||||
'eta': eta,
|
||||
'landed_dt': None,
|
||||
'status': 'DEPARTED',
|
||||
'status': flight.status.value,
|
||||
'isLocalFlight': True,
|
||||
'flight_type': flight.flight_type.value
|
||||
})
|
||||
@@ -97,11 +105,11 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||
# 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):
|
||||
# Only include BOOKED_IN, INBOUND and LANDED arrivals
|
||||
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND, ArrivalStatus.LANDED):
|
||||
continue
|
||||
# For BOOKED_IN, only include those created today
|
||||
if arrival.status == ArrivalStatus.BOOKED_IN:
|
||||
# For BOOKED_IN and INBOUND, only include those created today
|
||||
if arrival.status in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND):
|
||||
if not (today_start <= arrival.created_dt < today_end):
|
||||
continue
|
||||
# For LANDED, only include those landed today
|
||||
@@ -143,23 +151,26 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
'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())
|
||||
|
||||
# Add local flights awaiting takeoff that were booked out today.
|
||||
# Admin-created flights start at GROUND, while public pilot submissions start at BOOKED_OUT.
|
||||
local_departure_statuses = {
|
||||
LocalFlightStatus.BOOKED_OUT,
|
||||
LocalFlightStatus.GROUND,
|
||||
}
|
||||
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||
|
||||
# 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
|
||||
if flight.status not in local_departure_statuses:
|
||||
continue
|
||||
departures_list.append({
|
||||
'ac_call': flight.callsign or flight.registration,
|
||||
'ac_reg': flight.registration,
|
||||
@@ -167,16 +178,16 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
'out_to': None,
|
||||
'etd': flight.etd or flight.created_dt,
|
||||
'departed_dt': None,
|
||||
'status': 'BOOKED_OUT',
|
||||
'status': 'CONTACT' if flight.status == LocalFlightStatus.GROUND else 'BOOKED_OUT',
|
||||
'isLocalFlight': True,
|
||||
'flight_type': flight.flight_type.value,
|
||||
'isDeparture': False
|
||||
})
|
||||
|
||||
# Add departures to other airports with BOOKED_OUT status
|
||||
# Add departures to other airports with BOOKED_OUT and GROUND status
|
||||
departures_to_airports = crud_departure.get_multi(
|
||||
db,
|
||||
status=DepartureStatus.BOOKED_OUT,
|
||||
status=None, # Get all statuses
|
||||
limit=1000
|
||||
)
|
||||
|
||||
@@ -187,17 +198,25 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
|
||||
# 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):
|
||||
# Only include departures booked out today and not yet departed
|
||||
if not (today_start <= dep.created_dt < today_end) or dep.status == DepartureStatus.DEPARTED:
|
||||
continue
|
||||
|
||||
# Map status for display
|
||||
display_status = 'BOOKED_OUT'
|
||||
if dep.status == DepartureStatus.GROUND:
|
||||
display_status = 'CONTACT'
|
||||
elif dep.status == DepartureStatus.LOCAL:
|
||||
display_status = 'DEPARTED'
|
||||
|
||||
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',
|
||||
'departed_dt': dep.departed_dt,
|
||||
'status': display_status,
|
||||
'isLocalFlight': False,
|
||||
'isDeparture': True
|
||||
})
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db
|
||||
from app.core.config import settings
|
||||
from app.schemas.public_book import (
|
||||
PublicLocalFlightCreate,
|
||||
PublicCircuitCreate,
|
||||
PublicDepartureCreate,
|
||||
PublicArrivalCreate,
|
||||
)
|
||||
from app.schemas.local_flight import LocalFlight as LocalFlightSchema
|
||||
from app.schemas.circuit import Circuit as CircuitSchema
|
||||
from app.schemas.departure import Departure as DepartureSchema
|
||||
from app.schemas.arrival import Arrival as ArrivalSchema
|
||||
from app.crud.crud_local_flight import local_flight as crud_local_flight
|
||||
from app.crud.crud_circuit import crud_circuit
|
||||
from app.crud.crud_departure import departure as crud_departure
|
||||
from app.crud.crud_arrival import arrival as crud_arrival
|
||||
from app.models.local_flight import SubmissionSource
|
||||
from app.models.departure import DepartureStatus
|
||||
from app.models.arrival import SubmissionSource as ArrivalSubmissionSource
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def check_public_booking_enabled():
|
||||
"""Check if public booking is enabled"""
|
||||
if not settings.allow_public_booking:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Public booking is currently disabled"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/local-flights", response_model=LocalFlightSchema)
|
||||
async def public_book_local_flight(
|
||||
request: Request,
|
||||
flight_in: PublicLocalFlightCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Book a local flight via public portal"""
|
||||
check_public_booking_enabled()
|
||||
|
||||
# Create the flight with public submission source
|
||||
from app.schemas.local_flight import LocalFlightCreate
|
||||
|
||||
flight_create = LocalFlightCreate(
|
||||
registration=flight_in.registration,
|
||||
type=flight_in.type,
|
||||
callsign=flight_in.callsign,
|
||||
pob=flight_in.pob,
|
||||
flight_type=flight_in.flight_type,
|
||||
duration=flight_in.duration,
|
||||
etd=flight_in.etd,
|
||||
notes=flight_in.notes,
|
||||
)
|
||||
|
||||
flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||
|
||||
# Update with submission source and pilot email
|
||||
db.query(type(flight)).filter(type(flight).id == flight.id).update({
|
||||
type(flight).submitted_via: SubmissionSource.PUBLIC,
|
||||
type(flight).pilot_email: flight_in.pilot_email,
|
||||
})
|
||||
db.commit()
|
||||
db.refresh(flight)
|
||||
|
||||
# Send real-time update via WebSocket if available
|
||||
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,
|
||||
"submitted_via": "PUBLIC"
|
||||
}
|
||||
})
|
||||
|
||||
return flight
|
||||
|
||||
|
||||
@router.post("/circuits", response_model=CircuitSchema)
|
||||
async def public_record_circuit(
|
||||
request: Request,
|
||||
circuit_in: PublicCircuitCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Record a circuit (touch and go) via public portal"""
|
||||
check_public_booking_enabled()
|
||||
|
||||
from app.schemas.circuit import CircuitCreate
|
||||
|
||||
circuit_create = CircuitCreate(
|
||||
local_flight_id=circuit_in.local_flight_id,
|
||||
circuit_timestamp=circuit_in.circuit_timestamp,
|
||||
)
|
||||
|
||||
circuit = crud_circuit.create(db, obj_in=circuit_create, user="PUBLIC_PILOT", user_ip=request.client.host if request.client else None)
|
||||
|
||||
# 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(),
|
||||
"submitted_via": "PUBLIC"
|
||||
}
|
||||
})
|
||||
|
||||
return circuit
|
||||
|
||||
|
||||
@router.post("/departures", response_model=DepartureSchema)
|
||||
async def public_book_departure(
|
||||
request: Request,
|
||||
departure_in: PublicDepartureCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Book a departure via public portal"""
|
||||
check_public_booking_enabled()
|
||||
|
||||
from app.schemas.departure import DepartureCreate
|
||||
|
||||
departure_create = DepartureCreate(
|
||||
registration=departure_in.registration,
|
||||
type=departure_in.type,
|
||||
callsign=departure_in.callsign,
|
||||
pob=departure_in.pob,
|
||||
out_to=departure_in.out_to,
|
||||
etd=departure_in.etd,
|
||||
notes=departure_in.notes,
|
||||
)
|
||||
|
||||
departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||
|
||||
# Update with pilot email (submitted_via is already set in create method)
|
||||
db.query(type(departure)).filter(type(departure).id == departure.id).update({
|
||||
type(departure).pilot_email: departure_in.pilot_email,
|
||||
})
|
||||
db.commit()
|
||||
db.refresh(departure)
|
||||
|
||||
# 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,
|
||||
"status": departure.status.value,
|
||||
"submitted_via": "PUBLIC"
|
||||
}
|
||||
})
|
||||
|
||||
return departure
|
||||
|
||||
|
||||
@router.post("/arrivals", response_model=ArrivalSchema)
|
||||
async def public_book_arrival(
|
||||
request: Request,
|
||||
arrival_in: PublicArrivalCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Book an arrival via public portal"""
|
||||
check_public_booking_enabled()
|
||||
|
||||
from app.schemas.arrival import ArrivalCreate
|
||||
|
||||
arrival_create = ArrivalCreate(
|
||||
registration=arrival_in.registration,
|
||||
type=arrival_in.type,
|
||||
callsign=arrival_in.callsign,
|
||||
pob=arrival_in.pob,
|
||||
in_from=arrival_in.in_from,
|
||||
eta=arrival_in.eta,
|
||||
notes=arrival_in.notes,
|
||||
)
|
||||
|
||||
arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||
|
||||
# Update with pilot email
|
||||
db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({
|
||||
type(arrival).pilot_email: arrival_in.pilot_email,
|
||||
})
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
|
||||
# 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,
|
||||
"status": arrival.status.value,
|
||||
"submitted_via": "PUBLIC"
|
||||
}
|
||||
})
|
||||
|
||||
return arrival
|
||||
@@ -1,5 +1,4 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -33,8 +32,8 @@ class Settings(BaseSettings):
|
||||
top_bar_base_color: str = "#2c3e50"
|
||||
environment: str = "production" # production, development, staging, etc.
|
||||
|
||||
# Redis settings (for future use)
|
||||
redis_url: Optional[str] = None
|
||||
# Public booking settings
|
||||
allow_public_booking: bool = False # Enable/disable public flight booking
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
SWANSEA_FRZ_POLYGONS = [
|
||||
{
|
||||
"name": "EGR1U003A SWANSEA",
|
||||
"coordinates": [
|
||||
[51.6385691199, -4.0677777778],
|
||||
[51.6383925433, -4.0732789905],
|
||||
[51.6378646894, -4.0787217619],
|
||||
[51.6369911656, -4.0840482758],
|
||||
[51.6357812508, -4.0892019586],
|
||||
[51.6342477966, -4.0941280843],
|
||||
[51.6324070894, -4.0987743581],
|
||||
[51.6302786768, -4.1030914745],
|
||||
[51.6278851591, -4.1070336417],
|
||||
[51.6252519474, -4.1105590688],
|
||||
[51.6224069933, -4.1136304084],
|
||||
[51.6193804903, -4.1162151518],
|
||||
[51.6162045529, -4.1182859716],
|
||||
[51.6129128741, -4.1198210079],
|
||||
[51.6095403677, -4.1208040969],
|
||||
[51.606122797, -4.1212249367],
|
||||
[51.6026963949, -4.1210791924],
|
||||
[51.5992974802, -4.120368536],
|
||||
[51.595962072, -4.1191006239],
|
||||
[51.592725509, -4.1172890099],
|
||||
[51.5896220749, -4.1149529973],
|
||||
[51.5866846367, -4.1121174298],
|
||||
[51.5839442973, -4.1088124245],
|
||||
[51.5814300673, -4.1050730509],
|
||||
[51.5791685587, -4.1009389566],
|
||||
[51.5771837053, -4.0964539477],
|
||||
[51.5754965099, -4.0916655245],
|
||||
[51.5741248235, -4.0866243798],
|
||||
[51.5730831575, -4.0813838645],
|
||||
[51.572382531, -4.0759994259],
|
||||
[51.5720303553, -4.0705280237],
|
||||
[51.5720303553, -4.0650275318],
|
||||
[51.572382531, -4.0595561297],
|
||||
[51.5730831575, -4.054171691],
|
||||
[51.5741248235, -4.0489311758],
|
||||
[51.5754965099, -4.0438900311],
|
||||
[51.5771837053, -4.0391016078],
|
||||
[51.5791685587, -4.0346165989],
|
||||
[51.5814300673, -4.0304825047],
|
||||
[51.5839442973, -4.026743131],
|
||||
[51.5866846367, -4.0234381258],
|
||||
[51.5896220749, -4.0206025582],
|
||||
[51.592725509, -4.0182665456],
|
||||
[51.595962072, -4.0164549317],
|
||||
[51.5992974802, -4.0151870195],
|
||||
[51.6026963949, -4.0144763632],
|
||||
[51.606122797, -4.0143306189],
|
||||
[51.6095403677, -4.0147514587],
|
||||
[51.6129128741, -4.0157345476],
|
||||
[51.6162045529, -4.017269584],
|
||||
[51.6193804903, -4.0193404037],
|
||||
[51.6224069933, -4.0219251472],
|
||||
[51.6252519474, -4.0249964868],
|
||||
[51.6278851591, -4.0285219138],
|
||||
[51.6302786768, -4.0324640811],
|
||||
[51.6324070894, -4.0367811974],
|
||||
[51.6342477966, -4.0414274713],
|
||||
[51.6357812508, -4.0463535969],
|
||||
[51.6369911656, -4.0515072798],
|
||||
[51.6378646894, -4.0568337937],
|
||||
[51.6383925433, -4.0622765651],
|
||||
[51.6385691199, -4.0677777778],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003B SWANSEA RWY 04",
|
||||
"coordinates": [
|
||||
[51.5614305556, -4.1105694444],
|
||||
[51.5760447778, -4.0933516667],
|
||||
[51.5775992074, -4.0974772875],
|
||||
[51.5793789018, -4.1013615228],
|
||||
[51.5813693889, -4.1049727778],
|
||||
[51.5667527778, -4.1221888889],
|
||||
[51.5614305556, -4.1105694444],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003C SWANSEA RWY 22",
|
||||
"coordinates": [
|
||||
[51.6483027778, -4.0259555556],
|
||||
[51.6345286389, -4.0422406389],
|
||||
[51.632975828, -4.0381074205],
|
||||
[51.631197314, -4.0342163026],
|
||||
[51.6292076111, -4.030599],
|
||||
[51.6429805556, -4.0143111111],
|
||||
[51.6483027778, -4.0259555556],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003D SWANSEA RWY 10",
|
||||
"coordinates": [
|
||||
[51.6016305556, -4.1483194444],
|
||||
[51.5997253611, -4.1204896111],
|
||||
[51.602737017, -4.1210842429],
|
||||
[51.605769878, -4.1212361072],
|
||||
[51.60879875, -4.1209438611],
|
||||
[51.6105638889, -4.1467305556],
|
||||
[51.6016305556, -4.1483194444],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003E SWANSEA RWY 28",
|
||||
"coordinates": [
|
||||
[51.5998777778, -3.9918916667],
|
||||
[51.6014628333, -4.0146683056],
|
||||
[51.5984676719, -4.015448363],
|
||||
[51.5955291688, -4.0166629251],
|
||||
[51.5926717222, -4.0183018333],
|
||||
[51.5909444444, -3.9934777778],
|
||||
[51.5998777778, -3.9918916667],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def point_inside_swansea_frz(lat: float, lng: float) -> bool:
|
||||
"""Return whether a point is inside the Swansea UAS FRZ polygons from the KML source."""
|
||||
return any(_point_inside_polygon(lat, lng, polygon["coordinates"]) for polygon in SWANSEA_FRZ_POLYGONS)
|
||||
|
||||
|
||||
def swansea_frz_geojson() -> dict:
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": polygon["name"]},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[lng, lat] for lat, lng in polygon["coordinates"]
|
||||
]],
|
||||
},
|
||||
}
|
||||
for polygon in SWANSEA_FRZ_POLYGONS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _point_inside_polygon(lat: float, lng: float, coordinates: list[list[float]]) -> bool:
|
||||
inside = False
|
||||
j = len(coordinates) - 1
|
||||
for i, (point_lat, point_lng) in enumerate(coordinates):
|
||||
previous_lat, previous_lng = coordinates[j]
|
||||
intersects = (
|
||||
(point_lat > lat) != (previous_lat > lat)
|
||||
and lng < (previous_lng - point_lng) * (lat - point_lat) / (previous_lat - point_lat) + point_lng
|
||||
)
|
||||
if intersects:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
from app.crud.crud_movement import movement as movement_crud
|
||||
from app.schemas.movement import MovementCreate
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
class CRUDArrival:
|
||||
@@ -24,7 +27,17 @@ class CRUDArrival:
|
||||
query = db.query(Arrival)
|
||||
|
||||
if status:
|
||||
query = query.filter(Arrival.status == status)
|
||||
if status == ArrivalStatus.CIRCUIT:
|
||||
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
|
||||
circuit_statuses = [
|
||||
ArrivalStatus.CIRCUIT,
|
||||
ArrivalStatus.CIRCUIT_DOWNWIND,
|
||||
ArrivalStatus.CIRCUIT_BASE,
|
||||
ArrivalStatus.CIRCUIT_FINAL
|
||||
]
|
||||
query = query.filter(Arrival.status.in_(circuit_statuses))
|
||||
else:
|
||||
query = query.filter(Arrival.status == status)
|
||||
|
||||
if date_from:
|
||||
query = query.filter(func.date(Arrival.created_dt) >= date_from)
|
||||
@@ -35,27 +48,48 @@ class CRUDArrival:
|
||||
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)"""
|
||||
"""Get today's arrivals (booked in, inbound 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.INBOUND,
|
||||
Arrival.status == ArrivalStatus.LANDED
|
||||
)
|
||||
)
|
||||
).order_by(Arrival.created_dt).all()
|
||||
|
||||
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
|
||||
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Arrival:
|
||||
from app.models.arrival import SubmissionSource
|
||||
|
||||
# Set initial status based on submission source
|
||||
initial_status = ArrivalStatus.BOOKED_IN
|
||||
|
||||
if submitted_via == SubmissionSource.ADMIN:
|
||||
initial_status = ArrivalStatus.INBOUND
|
||||
|
||||
db_obj = Arrival(
|
||||
**obj_in.dict(),
|
||||
**obj_in.dict(exclude={'submitted_via'}),
|
||||
created_by=created_by,
|
||||
status=ArrivalStatus.BOOKED_IN
|
||||
status=initial_status,
|
||||
submitted_via=submitted_via
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log creation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.ARRIVAL,
|
||||
db_obj.id,
|
||||
f"Arrival created: {db_obj.registration}",
|
||||
created_by,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
|
||||
@@ -113,13 +147,52 @@ class CRUDArrival:
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
|
||||
if status == ArrivalStatus.LANDED and timestamp:
|
||||
db_obj.landed_dt = timestamp
|
||||
# Set timestamps based on status
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == ArrivalStatus.LANDED:
|
||||
db_obj.landed_dt = current_time
|
||||
elif status == ArrivalStatus.ARRIVED:
|
||||
db_obj.arrived_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Create movement record if applicable
|
||||
if status == ArrivalStatus.LANDED and db_obj.landed_dt:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.LANDING,
|
||||
aircraft_registration=db_obj.registration,
|
||||
aircraft_type=db_obj.type,
|
||||
callsign=db_obj.callsign,
|
||||
timestamp=db_obj.landed_dt,
|
||||
entity_type="ARRIVAL",
|
||||
entity_id=arrival_id,
|
||||
from_location=db_obj.in_from,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
|
||||
# Promote any PENDING departure linked to this arrival to BOOKED_OUT
|
||||
from app.models.departure import Departure as DepartureModel, DepartureStatus as DepStatus
|
||||
pending_dep = db.query(DepartureModel).filter(
|
||||
DepartureModel.arrival_id == arrival_id,
|
||||
DepartureModel.status == DepStatus.PENDING
|
||||
).first()
|
||||
if pending_dep:
|
||||
pending_dep.status = DepStatus.BOOKED_OUT
|
||||
db.add(pending_dep)
|
||||
db.commit()
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.ARRIVAL,
|
||||
arrival_id,
|
||||
f"Linked departure #{pending_dep.id} promoted to BOOKED_OUT on landing",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
# Log status change in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
@@ -132,15 +205,27 @@ class CRUDArrival:
|
||||
|
||||
return db_obj
|
||||
|
||||
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||
def cancel(self, db: Session, arrival_id: int, 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 = ArrivalStatus.CANCELLED
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log cancellation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.ARRIVAL,
|
||||
arrival_id,
|
||||
f"Status changed from {old_status.value} to CANCELLED",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from app.models.circuit import Circuit
|
||||
from app.schemas.circuit import CircuitCreate, CircuitUpdate
|
||||
from app.models.journal import EntityType
|
||||
from app.crud.crud_journal import journal
|
||||
from app.crud.crud_movement import movement as movement_crud
|
||||
from app.schemas.movement import MovementCreate
|
||||
from app.models.movement import MovementType
|
||||
from app.crud.crud_local_flight import local_flight as crud_local_flight
|
||||
from app.crud.crud_arrival import arrival as crud_arrival
|
||||
|
||||
|
||||
class CRUDCircuit:
|
||||
@@ -16,6 +23,12 @@ class CRUDCircuit:
|
||||
Circuit.local_flight_id == local_flight_id
|
||||
).order_by(Circuit.circuit_timestamp).all()
|
||||
|
||||
def get_by_arrival(self, db: Session, arrival_id: int) -> List[Circuit]:
|
||||
"""Get all circuits for a specific arrival"""
|
||||
return db.query(Circuit).filter(
|
||||
Circuit.arrival_id == arrival_id
|
||||
).order_by(Circuit.circuit_timestamp).all()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -24,30 +37,116 @@ class CRUDCircuit:
|
||||
) -> 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:
|
||||
def create(self, db: Session, obj_in: CircuitCreate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
|
||||
db_obj = Circuit(
|
||||
local_flight_id=obj_in.local_flight_id,
|
||||
arrival_id=obj_in.arrival_id,
|
||||
circuit_timestamp=obj_in.circuit_timestamp
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log circuit creation in journal
|
||||
# Use LOCAL_FLIGHT entity type if local_flight_id exists, otherwise ARRIVAL
|
||||
entity_type = EntityType.LOCAL_FLIGHT if obj_in.local_flight_id else EntityType.ARRIVAL
|
||||
entity_id = obj_in.local_flight_id if obj_in.local_flight_id else obj_in.arrival_id
|
||||
|
||||
journal.log_change(
|
||||
db,
|
||||
entity_type,
|
||||
entity_id,
|
||||
f"Circuit recorded at {obj_in.circuit_timestamp.isoformat()}",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
# Create TOUCH_AND_GO movement
|
||||
if obj_in.local_flight_id:
|
||||
flight = crud_local_flight.get(db, obj_in.local_flight_id)
|
||||
if flight:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.TOUCH_AND_GO,
|
||||
aircraft_registration=flight.registration,
|
||||
aircraft_type=flight.type,
|
||||
callsign=flight.callsign,
|
||||
timestamp=obj_in.circuit_timestamp,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=obj_in.local_flight_id,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
elif obj_in.arrival_id:
|
||||
arrival = crud_arrival.get(db, obj_in.arrival_id)
|
||||
if arrival:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.TOUCH_AND_GO,
|
||||
aircraft_registration=arrival.registration,
|
||||
aircraft_type=arrival.type,
|
||||
callsign=arrival.callsign,
|
||||
timestamp=obj_in.circuit_timestamp,
|
||||
entity_type="ARRIVAL",
|
||||
entity_id=obj_in.arrival_id,
|
||||
from_location=arrival.in_from,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
|
||||
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
|
||||
obj_data = obj_in.dict(exclude_unset=True)
|
||||
changes = []
|
||||
|
||||
for field, value in obj_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
old_value = getattr(db_obj, field)
|
||||
if old_value != value:
|
||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log changes in journal if any were made
|
||||
if changes:
|
||||
entity_type = EntityType.LOCAL_FLIGHT if db_obj.local_flight_id else EntityType.ARRIVAL
|
||||
entity_id = db_obj.local_flight_id if db_obj.local_flight_id else db_obj.arrival_id
|
||||
|
||||
for change in changes:
|
||||
journal.log_change(
|
||||
db,
|
||||
entity_type,
|
||||
entity_id,
|
||||
f"Circuit: {change}",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def delete(self, db: Session, circuit_id: int) -> bool:
|
||||
def delete(self, db: Session, circuit_id: int, user: str = "system", user_ip: Optional[str] = None) -> bool:
|
||||
circuit = self.get(db, circuit_id)
|
||||
if circuit:
|
||||
# Determine which entity this circuit belongs to
|
||||
entity_type = EntityType.LOCAL_FLIGHT if circuit.local_flight_id else EntityType.ARRIVAL
|
||||
entity_id = circuit.local_flight_id if circuit.local_flight_id else circuit.arrival_id
|
||||
|
||||
db.delete(circuit)
|
||||
db.commit()
|
||||
|
||||
# Log deletion in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
entity_type,
|
||||
entity_id,
|
||||
f"Circuit deleted (recorded at {circuit.circuit_timestamp.isoformat()})",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
from app.crud.crud_movement import movement as movement_crud
|
||||
from app.schemas.movement import MovementCreate
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
class CRUDDeparture:
|
||||
@@ -47,15 +50,47 @@ class CRUDDeparture:
|
||||
)
|
||||
).order_by(Departure.created_dt).all()
|
||||
|
||||
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
|
||||
def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Departure:
|
||||
from app.models.departure import SubmissionSource
|
||||
|
||||
# Set initial status based on submission source
|
||||
initial_status = DepartureStatus.BOOKED_OUT
|
||||
contact_dt = None
|
||||
|
||||
if submitted_via == SubmissionSource.ADMIN:
|
||||
initial_status = DepartureStatus.GROUND
|
||||
contact_dt = func.now() # Set contact_dt to creation time for admin submissions
|
||||
|
||||
obj_data = obj_in.dict()
|
||||
arrival_id = obj_data.pop('arrival_id', None)
|
||||
|
||||
# If arrival_id is provided this is a PPR-linked departure — stay PENDING until arrival lands
|
||||
if arrival_id is not None:
|
||||
initial_status = DepartureStatus.PENDING
|
||||
contact_dt = None
|
||||
|
||||
db_obj = Departure(
|
||||
**obj_in.dict(),
|
||||
**obj_data,
|
||||
arrival_id=arrival_id,
|
||||
created_by=created_by,
|
||||
status=DepartureStatus.BOOKED_OUT
|
||||
status=initial_status,
|
||||
contact_dt=contact_dt,
|
||||
submitted_via=submitted_via
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log creation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.DEPARTURE,
|
||||
db_obj.id,
|
||||
f"Departure created: {db_obj.registration}",
|
||||
created_by,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
|
||||
@@ -113,13 +148,35 @@ class CRUDDeparture:
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
|
||||
if status == DepartureStatus.DEPARTED and timestamp:
|
||||
db_obj.departed_dt = timestamp
|
||||
# Set timestamps based on status
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == DepartureStatus.GROUND:
|
||||
db_obj.contact_dt = current_time
|
||||
elif status == DepartureStatus.DEPARTED:
|
||||
db_obj.departed_dt = current_time
|
||||
elif status == DepartureStatus.LOCAL:
|
||||
db_obj.takeoff_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Create movement record if applicable
|
||||
if db_obj.takeoff_dt and status == DepartureStatus.LOCAL:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.TAKEOFF,
|
||||
aircraft_registration=db_obj.registration,
|
||||
aircraft_type=db_obj.type,
|
||||
callsign=db_obj.callsign,
|
||||
timestamp=db_obj.takeoff_dt,
|
||||
entity_type="DEPARTURE",
|
||||
entity_id=departure_id,
|
||||
to_location=db_obj.out_to,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
|
||||
# Log status change in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
@@ -132,15 +189,27 @@ class CRUDDeparture:
|
||||
|
||||
return db_obj
|
||||
|
||||
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||
def cancel(self, db: Session, departure_id: int, 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 = DepartureStatus.CANCELLED
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log cancellation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.DEPARTURE,
|
||||
departure_id,
|
||||
f"Status changed from {old_status.value} to CANCELLED",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.frz import point_inside_swansea_frz
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.drone_request import DroneRequest, DroneRequestStatus
|
||||
from app.models.journal import EntityType
|
||||
from app.schemas.drone_request import DroneRequestCreate, DroneRequestUpdate
|
||||
|
||||
|
||||
class CRUDDroneRequest:
|
||||
def get(self, db: Session, request_id: int) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.id == request_id).first()
|
||||
|
||||
def get_by_reference(self, db: Session, reference_number: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.reference_number == reference_number).first()
|
||||
|
||||
def get_by_public_token(self, db: Session, token: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.public_token == token).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
) -> List[DroneRequest]:
|
||||
query = db.query(DroneRequest)
|
||||
|
||||
if status:
|
||||
query = query.filter(DroneRequest.status == status)
|
||||
if date_from:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) <= date_to)
|
||||
|
||||
return query.order_by(desc(DroneRequest.submitted_at)).offset(skip).limit(limit).all()
|
||||
|
||||
def create(
|
||||
self,
|
||||
db: Session,
|
||||
obj_in: DroneRequestCreate,
|
||||
created_by: str = "public",
|
||||
user_ip: str = "127.0.0.1",
|
||||
submitted_via: str = "PUBLIC",
|
||||
) -> DroneRequest:
|
||||
reference_number = self._generate_reference(db)
|
||||
payload = obj_in.dict()
|
||||
notes = payload.pop("notes", None)
|
||||
payload.pop("prototype_overlay", None)
|
||||
payload.pop("location_inside_frz", None)
|
||||
|
||||
db_obj = DroneRequest(
|
||||
**payload,
|
||||
applicant_notes=notes,
|
||||
location_inside_frz=point_inside_swansea_frz(payload["location_latitude"], payload["location_longitude"]),
|
||||
reference_number=reference_number,
|
||||
public_token=secrets.token_urlsafe(64),
|
||||
status=DroneRequestStatus.NEW,
|
||||
created_by=created_by,
|
||||
submitted_ip=user_ip,
|
||||
submitted_via=submitted_via,
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.DRONE_REQUEST,
|
||||
db_obj.id,
|
||||
f"Drone request {db_obj.reference_number} created",
|
||||
created_by,
|
||||
user_ip,
|
||||
)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self,
|
||||
db: Session,
|
||||
db_obj: DroneRequest,
|
||||
obj_in: DroneRequestUpdate,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> DroneRequest:
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
if "notes" in update_data:
|
||||
update_data["applicant_notes"] = update_data.pop("notes")
|
||||
update_data.pop("prototype_overlay", None)
|
||||
update_data.pop("location_inside_frz", None)
|
||||
|
||||
if "location_latitude" in update_data or "location_longitude" in update_data:
|
||||
lat = update_data.get("location_latitude", db_obj.location_latitude)
|
||||
lng = update_data.get("location_longitude", db_obj.location_longitude)
|
||||
update_data["location_inside_frz"] = point_inside_swansea_frz(lat, lng)
|
||||
changes = []
|
||||
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(db_obj, field)
|
||||
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)
|
||||
for change in changes:
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, change, user, user_ip)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
status: DroneRequestStatus,
|
||||
comment: Optional[str] = None,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
db_obj.status_changed_at = datetime.utcnow()
|
||||
db_obj.status_changed_by = user
|
||||
if comment:
|
||||
db_obj.operator_comments = comment
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
entry = f"Status changed from {old_status.value} to {status.value}"
|
||||
if comment:
|
||||
entry = f"{entry}: {comment}"
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, entry, user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def add_comment(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
comment: str,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
db_obj.operator_comments = comment
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, f"Comment added: {comment}", user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def _generate_reference(self, db: Session) -> str:
|
||||
prefix = f"DRN-{datetime.utcnow().strftime('%y%m%d')}"
|
||||
references = db.query(DroneRequest.reference_number).filter(
|
||||
DroneRequest.reference_number.like(f"{prefix}-%")
|
||||
).all()
|
||||
|
||||
highest_sequence = 0
|
||||
for (reference_number,) in references:
|
||||
suffix = reference_number.rsplit("-", 1)[-1]
|
||||
if suffix.isdigit():
|
||||
highest_sequence = max(highest_sequence, int(suffix))
|
||||
|
||||
for sequence in range(highest_sequence + 1, highest_sequence + 11):
|
||||
candidate = f"{prefix}-{sequence}"
|
||||
if not self.get_by_reference(db, candidate):
|
||||
return candidate
|
||||
|
||||
return f"{prefix}-{highest_sequence + 11}"
|
||||
|
||||
|
||||
drone_request = CRUDDroneRequest()
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from app.models.journal import JournalEntry, EntityType
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
class CRUDJournal:
|
||||
@@ -58,6 +59,41 @@ class CRUDJournal:
|
||||
JournalEntry.user == user
|
||||
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||
|
||||
def search_entries(
|
||||
self,
|
||||
db: Session,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[int] = None,
|
||||
user: Optional[str] = None,
|
||||
limit: int = 500
|
||||
) -> List[JournalEntry]:
|
||||
"""Search journal entries with optional filters."""
|
||||
query = db.query(JournalEntry)
|
||||
|
||||
# Apply date filters
|
||||
if date_from:
|
||||
query = query.filter(func.date(JournalEntry.entry_dt) >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.filter(func.date(JournalEntry.entry_dt) <= date_to)
|
||||
|
||||
# Apply entity type filter
|
||||
if entity_type:
|
||||
query = query.filter(JournalEntry.entity_type == entity_type.upper())
|
||||
|
||||
# Apply entity ID filter
|
||||
if entity_id:
|
||||
query = query.filter(JournalEntry.entity_id == entity_id)
|
||||
|
||||
# Apply user filter
|
||||
if user:
|
||||
query = query.filter(JournalEntry.user == user)
|
||||
|
||||
# Order by date descending (newest first) and apply limit
|
||||
return query.order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||
|
||||
# Convenience methods for backward compatibility with PPR journal
|
||||
def log_ppr_change(
|
||||
self,
|
||||
|
||||
@@ -7,6 +7,9 @@ from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, Local
|
||||
from app.models.journal import EntityType
|
||||
from app.models.circuit import Circuit
|
||||
from app.crud.crud_journal import journal
|
||||
from app.crud.crud_movement import movement as movement_crud
|
||||
from app.schemas.movement import MovementCreate
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
class CRUDLocalFlight:
|
||||
@@ -26,7 +29,17 @@ class CRUDLocalFlight:
|
||||
query = db.query(LocalFlight)
|
||||
|
||||
if status:
|
||||
query = query.filter(LocalFlight.status == status)
|
||||
if status == LocalFlightStatus.CIRCUIT:
|
||||
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
|
||||
circuit_statuses = [
|
||||
LocalFlightStatus.CIRCUIT,
|
||||
LocalFlightStatus.CIRCUIT_DOWNWIND,
|
||||
LocalFlightStatus.CIRCUIT_BASE,
|
||||
LocalFlightStatus.CIRCUIT_FINAL
|
||||
]
|
||||
query = query.filter(LocalFlight.status.in_(circuit_statuses))
|
||||
else:
|
||||
query = query.filter(LocalFlight.status == status)
|
||||
|
||||
if flight_type:
|
||||
query = query.filter(LocalFlight.flight_type == flight_type)
|
||||
@@ -74,15 +87,35 @@ class CRUDLocalFlight:
|
||||
)
|
||||
).order_by(LocalFlight.created_dt).all()
|
||||
|
||||
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
|
||||
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> LocalFlight:
|
||||
from app.models.local_flight import SubmissionSource
|
||||
|
||||
# Set initial status based on submission source
|
||||
initial_status = LocalFlightStatus.BOOKED_OUT
|
||||
|
||||
if submitted_via == SubmissionSource.ADMIN:
|
||||
initial_status = LocalFlightStatus.GROUND
|
||||
|
||||
db_obj = LocalFlight(
|
||||
**obj_in.dict(),
|
||||
created_by=created_by,
|
||||
status=LocalFlightStatus.BOOKED_OUT
|
||||
status=initial_status,
|
||||
submitted_via=submitted_via
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log creation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.LOCAL_FLIGHT,
|
||||
db_obj.id,
|
||||
f"Local flight created: {db_obj.registration} ({db_obj.flight_type.value})",
|
||||
created_by,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
|
||||
@@ -144,11 +177,19 @@ class CRUDLocalFlight:
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
|
||||
# Update flight_type based on status changes
|
||||
if status == LocalFlightStatus.LOCAL:
|
||||
db_obj.flight_type = LocalFlightType.LOCAL
|
||||
elif status == LocalFlightStatus.CIRCUIT:
|
||||
db_obj.flight_type = LocalFlightType.CIRCUITS
|
||||
|
||||
# Set timestamps based on status
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == LocalFlightStatus.DEPARTED:
|
||||
if status == LocalFlightStatus.GROUND:
|
||||
db_obj.contact_dt = current_time
|
||||
elif status == LocalFlightStatus.DEPARTED:
|
||||
db_obj.departed_dt = current_time
|
||||
elif status == LocalFlightStatus.LANDED:
|
||||
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
|
||||
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(
|
||||
@@ -156,10 +197,42 @@ class CRUDLocalFlight:
|
||||
).scalar()
|
||||
db_obj.circuits = circuit_count
|
||||
|
||||
# Takeoff: happens once when transitioning away from GROUND
|
||||
if old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT) and not db_obj.takeoff_dt:
|
||||
db_obj.takeoff_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Create movement record if applicable
|
||||
if db_obj.takeoff_dt and old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT):
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.TAKEOFF,
|
||||
aircraft_registration=db_obj.registration,
|
||||
aircraft_type=db_obj.type,
|
||||
callsign=db_obj.callsign,
|
||||
timestamp=db_obj.takeoff_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=flight_id,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
if db_obj.landed_dt and status == LocalFlightStatus.LANDED:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.LANDING,
|
||||
aircraft_registration=db_obj.registration,
|
||||
aircraft_type=db_obj.type,
|
||||
callsign=db_obj.callsign,
|
||||
timestamp=db_obj.landed_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=flight_id,
|
||||
created_by=user,
|
||||
ip_address=user_ip
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
|
||||
# Log status change in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
@@ -172,13 +245,24 @@ class CRUDLocalFlight:
|
||||
|
||||
return db_obj
|
||||
|
||||
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
||||
def cancel(self, db: Session, flight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[LocalFlight]:
|
||||
db_obj = self.get(db, flight_id)
|
||||
if db_obj:
|
||||
old_status = db_obj.status
|
||||
db_obj.status = LocalFlightStatus.CANCELLED
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log cancellation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.LOCAL_FLIGHT,
|
||||
flight_id,
|
||||
f"Status changed from {old_status.value} to CANCELLED",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
return db_obj
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
from datetime import date, datetime
|
||||
from app.models.movement import Movement, MovementType
|
||||
from app.schemas.movement import MovementCreate
|
||||
|
||||
|
||||
class CRUDMovement:
|
||||
def get(self, db: Session, movement_id: int) -> Optional[Movement]:
|
||||
return db.query(Movement).filter(Movement.id == movement_id).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
movement_type: Optional[MovementType] = None,
|
||||
aircraft_registration: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entity_type: Optional[str] = None
|
||||
) -> List[Movement]:
|
||||
query = db.query(Movement)
|
||||
|
||||
if movement_type:
|
||||
query = query.filter(Movement.movement_type == movement_type)
|
||||
|
||||
if aircraft_registration:
|
||||
query = query.filter(Movement.aircraft_registration.ilike(f"%{aircraft_registration}%"))
|
||||
|
||||
if date_from:
|
||||
query = query.filter(func.date(Movement.timestamp) >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.filter(func.date(Movement.timestamp) <= date_to)
|
||||
|
||||
if entity_type:
|
||||
query = query.filter(Movement.entity_type == entity_type)
|
||||
|
||||
return query.order_by(Movement.timestamp.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
def create(self, db: Session, obj_in: MovementCreate) -> Movement:
|
||||
db_obj = Movement(**obj_in.dict())
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: Movement, obj_in: MovementCreate) -> Movement:
|
||||
update_data = obj_in.dict()
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def find_daily_match(
|
||||
self,
|
||||
db: Session,
|
||||
target_date: date,
|
||||
aircraft_registration: str,
|
||||
movement_type: MovementType,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_types: Optional[List[str]] = None
|
||||
) -> Optional[Movement]:
|
||||
clean_reg = "".join(char for char in aircraft_registration.upper() if char.isalnum())
|
||||
clean_column = func.upper(func.replace(func.replace(Movement.aircraft_registration, "-", ""), " ", ""))
|
||||
query = db.query(Movement).filter(
|
||||
func.date(Movement.timestamp) == target_date,
|
||||
clean_column == clean_reg,
|
||||
Movement.movement_type == movement_type
|
||||
)
|
||||
if entity_type:
|
||||
query = query.filter(Movement.entity_type == entity_type)
|
||||
if entity_types:
|
||||
query = query.filter(Movement.entity_type.in_(entity_types))
|
||||
return query.order_by(Movement.timestamp.desc()).first()
|
||||
|
||||
def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]:
|
||||
return db.query(Movement).filter(
|
||||
and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id)
|
||||
).order_by(Movement.timestamp).all()
|
||||
|
||||
def get_daily_movements(self, db: Session, target_date: date) -> List[Movement]:
|
||||
return db.query(Movement).filter(
|
||||
func.date(Movement.timestamp) == target_date
|
||||
).order_by(Movement.timestamp).all()
|
||||
|
||||
|
||||
movement = CRUDMovement()
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
from app.crud.crud_movement import movement as movement_crud
|
||||
from app.schemas.movement import MovementCreate
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
class CRUDOverflight:
|
||||
@@ -57,6 +60,21 @@ class CRUDOverflight:
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Create OVERFLIGHT movement if call_dt is set
|
||||
if db_obj.call_dt:
|
||||
movement_data = MovementCreate(
|
||||
movement_type=MovementType.OVERFLIGHT,
|
||||
aircraft_registration=db_obj.registration,
|
||||
aircraft_type=db_obj.type,
|
||||
timestamp=db_obj.call_dt,
|
||||
entity_type="OVERFLIGHT",
|
||||
entity_id=db_obj.id,
|
||||
from_location=db_obj.departure_airfield,
|
||||
to_location=db_obj.destination_airfield,
|
||||
created_by=created_by
|
||||
)
|
||||
movement_crud.create(db, movement_data)
|
||||
|
||||
# Log creation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
|
||||
@@ -169,6 +169,36 @@ class CRUDPPR:
|
||||
|
||||
return db_obj
|
||||
|
||||
def acknowledge_strip(
|
||||
self,
|
||||
db: Session,
|
||||
ppr_id: int,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1"
|
||||
) -> Optional[PPRRecord]:
|
||||
db_obj = self.get(db, ppr_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
if db_obj.acknowledged_dt:
|
||||
return db_obj
|
||||
|
||||
db_obj.acknowledged_dt = datetime.utcnow()
|
||||
db_obj.acknowledged_by = user
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_ppr_change(
|
||||
db,
|
||||
db_obj.id,
|
||||
f"Paper strip acknowledged by {user}",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]:
|
||||
db_obj = self.get(db, ppr_id)
|
||||
if db_obj:
|
||||
@@ -182,6 +212,7 @@ class CRUDPPR:
|
||||
# Log the deletion in journal
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
db_obj.id,
|
||||
f"PPR marked as DELETED (was {old_status.value})",
|
||||
user,
|
||||
|
||||
@@ -3,6 +3,8 @@ from sqlalchemy.orm import Session
|
||||
from app.models.ppr import User
|
||||
from app.schemas.ppr import UserCreate, UserUpdate
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models.journal import EntityType
|
||||
from app.crud.crud_journal import journal
|
||||
|
||||
|
||||
class CRUDUser:
|
||||
@@ -15,7 +17,7 @@ class CRUDUser:
|
||||
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
return db.query(User).offset(skip).limit(limit).all()
|
||||
|
||||
def create(self, db: Session, obj_in: UserCreate) -> User:
|
||||
def create(self, db: Session, obj_in: UserCreate, admin_user: str = "system") -> User:
|
||||
hashed_password = get_password_hash(obj_in.password)
|
||||
db_obj = User(
|
||||
username=obj_in.username,
|
||||
@@ -25,17 +27,46 @@ class CRUDUser:
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log user creation in journal
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.USER,
|
||||
db_obj.id,
|
||||
f"User created: {obj_in.username} with role {obj_in.role}",
|
||||
admin_user,
|
||||
None
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: User, obj_in: UserUpdate) -> User:
|
||||
def update(self, db: Session, db_obj: User, obj_in: UserUpdate, admin_user: str = "system") -> User:
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
changes = []
|
||||
if "password" in update_data:
|
||||
update_data["password"] = get_password_hash(update_data["password"])
|
||||
changes.append("password changed")
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
old_value = getattr(db_obj, field)
|
||||
if field == "password" or old_value != value:
|
||||
if field != "password": # Don't log actual password values
|
||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||
setattr(db_obj, field, value)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
# Log user update in journal
|
||||
if changes:
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.USER,
|
||||
db_obj.id,
|
||||
"; ".join(changes),
|
||||
admin_user,
|
||||
None
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]:
|
||||
@@ -50,13 +81,24 @@ class CRUDUser:
|
||||
# For future use if we add user status
|
||||
return True
|
||||
|
||||
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
|
||||
def change_password(self, db: Session, db_obj: User, new_password: str, admin_user: str = "system") -> 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)
|
||||
|
||||
# Log password change in journal (security audit)
|
||||
journal.log_change(
|
||||
db,
|
||||
EntityType.USER,
|
||||
db_obj.id,
|
||||
f"Password changed by {admin_user}",
|
||||
admin_user,
|
||||
None
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
|
||||
+8
-87
@@ -1,10 +1,8 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import redis.asyncio as redis
|
||||
from app.core.config import settings
|
||||
from app.api.api import api_router
|
||||
|
||||
@@ -14,15 +12,14 @@ 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
|
||||
from app.models.circuit import Circuit
|
||||
from app.models.movement import Movement
|
||||
from app.models.drone_request import DroneRequest
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client for pub/sub (cross-worker communication)
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.project_name,
|
||||
openapi_url=f"{settings.api_v1_str}/openapi.json",
|
||||
@@ -43,7 +40,6 @@ app.add_middleware(
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
self.redis_listener_task = None
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -58,10 +54,11 @@ class ConnectionManager:
|
||||
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||
await websocket.send_text(message)
|
||||
|
||||
async def broadcast_local(self, message_str: str):
|
||||
"""Broadcast to connections on this worker only"""
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast an update to every websocket connected to this process."""
|
||||
message_str = json.dumps(message)
|
||||
dead_connections = []
|
||||
for connection in self.active_connections:
|
||||
for connection in list(self.active_connections):
|
||||
try:
|
||||
await connection.send_text(message_str)
|
||||
except Exception as e:
|
||||
@@ -76,84 +73,8 @@ class ConnectionManager:
|
||||
if dead_connections:
|
||||
logger.info(f"Removed {len(dead_connections)} dead connections")
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast via Redis pub/sub to all workers"""
|
||||
message_str = json.dumps(message)
|
||||
print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||
logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||
|
||||
try:
|
||||
if redis_client:
|
||||
await redis_client.publish('ppr_updates', message_str)
|
||||
print(f"✓ Message published to Redis")
|
||||
else:
|
||||
# Fallback to local broadcast if Redis not available
|
||||
print("⚠ Redis not available, falling back to local broadcast")
|
||||
logger.warning("Redis not available, falling back to local broadcast")
|
||||
await self.broadcast_local(message_str)
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to publish to Redis: {e}")
|
||||
logger.error(f"Failed to publish to Redis: {e}")
|
||||
# Fallback to local broadcast
|
||||
await self.broadcast_local(message_str)
|
||||
|
||||
async def start_redis_listener(self):
|
||||
"""Listen for Redis pub/sub messages and broadcast to local connections"""
|
||||
global redis_client, pubsub
|
||||
|
||||
try:
|
||||
# Connect to Redis
|
||||
redis_url = settings.redis_url or "redis://redis:6379"
|
||||
print(f"Connecting to Redis at: {redis_url}")
|
||||
redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe('ppr_updates')
|
||||
|
||||
print("✓ Redis listener started for PPR updates")
|
||||
logger.info("Redis listener started for PPR updates")
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
message_data = message['data']
|
||||
print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||
logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||
await self.broadcast_local(message_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Redis listener error: {e}")
|
||||
logger.error(f"Redis listener error: {e}")
|
||||
await asyncio.sleep(5) # Wait before retry
|
||||
# Retry connection
|
||||
if self.redis_listener_task and not self.redis_listener_task.done():
|
||||
asyncio.create_task(self.start_redis_listener())
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Start Redis listener when application starts"""
|
||||
print("=" * 50)
|
||||
print("STARTUP: Starting application and Redis listener...")
|
||||
print("=" * 50)
|
||||
logger.info("Starting application and Redis listener...")
|
||||
manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Clean up Redis connections on shutdown"""
|
||||
logger.info("Shutting down application...")
|
||||
global redis_client, pubsub
|
||||
|
||||
if manager.redis_listener_task:
|
||||
manager.redis_listener_task.cancel()
|
||||
|
||||
if pubsub:
|
||||
await pubsub.unsubscribe('ppr_updates')
|
||||
await pubsub.close()
|
||||
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
|
||||
@app.websocket("/ws/tower-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
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
|
||||
from app.db.session import Base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class ArrivalStatus(str, Enum):
|
||||
BOOKED_IN = "BOOKED_IN"
|
||||
INBOUND = "INBOUND"
|
||||
LANDED = "LANDED"
|
||||
GROUND = "GROUND"
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUIT = "CIRCUIT"
|
||||
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
|
||||
CIRCUIT_BASE = "CIRCUIT_BASE"
|
||||
CIRCUIT_FINAL = "CIRCUIT_FINAL"
|
||||
ARRIVED = "ARRIVED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@@ -21,10 +32,13 @@ class Arrival(Base):
|
||||
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)
|
||||
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.INBOUND, 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)
|
||||
arrived_dt = Column(DateTime, nullable=True) # Time when aircraft parks and shuts down
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
|
||||
pilot_email = Column(String(128), nullable=True) # For public submissions
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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)
|
||||
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
arrival_id = Column(BigInteger, ForeignKey("arrivals.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
circuit_timestamp = Column(DateTime, nullable=False, index=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
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
|
||||
from app.db.session import Base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class DepartureStatus(str, Enum):
|
||||
BOOKED_OUT = "BOOKED_OUT"
|
||||
GROUND = "GROUND"
|
||||
DEPARTED = "DEPARTED"
|
||||
LOCAL = "LOCAL"
|
||||
CANCELLED = "CANCELLED"
|
||||
PENDING = "PENDING"
|
||||
|
||||
|
||||
class Departure(Base):
|
||||
@@ -25,6 +31,11 @@ class Departure(Base):
|
||||
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
|
||||
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
|
||||
departed_dt = Column(DateTime, nullable=True) # Actual departure time (QSY)
|
||||
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
|
||||
pilot_email = Column(String(128), nullable=True) # For public submissions
|
||||
arrival_id = Column(BigInteger, nullable=True) # Linked arrival for PPR-activated departures
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Date, DateTime, Enum as SQLEnum, Float, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequest(Base):
|
||||
__tablename__ = "drone_requests"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
reference_number = Column(String(24), nullable=False, unique=True, index=True)
|
||||
public_token = Column(String(128), nullable=True, unique=True, index=True)
|
||||
status = Column(SQLEnum(DroneRequestStatus), nullable=False, default=DroneRequestStatus.NEW, index=True)
|
||||
|
||||
operator_name = Column(String(128), nullable=False, index=True)
|
||||
operator_id = Column(String(64), nullable=True)
|
||||
flyer_name = Column(String(128), nullable=True)
|
||||
flyer_id = Column(String(64), nullable=True)
|
||||
email = Column(String(128), nullable=False, index=True)
|
||||
phone = Column(String(32), nullable=True)
|
||||
|
||||
flight_date = Column(Date, nullable=True, index=True)
|
||||
estimated_takeoff_time = Column(String(8), nullable=True)
|
||||
estimated_completion_time = Column(String(8), nullable=True)
|
||||
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
||||
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
||||
maximum_elevation_ft_amsl = Column(Integer, nullable=False)
|
||||
|
||||
location_description = Column(Text, nullable=True)
|
||||
location_latitude = Column(Float, nullable=False)
|
||||
location_longitude = Column(Float, nullable=False)
|
||||
location_inside_frz = Column(Boolean, nullable=True)
|
||||
prototype_overlay = Column(JSON, nullable=True)
|
||||
|
||||
applicant_notes = Column(Text, nullable=True)
|
||||
operator_comments = Column(Text, nullable=True)
|
||||
submitted_via = Column(String(32), nullable=False, default="PUBLIC")
|
||||
submitted_ip = Column(String(45), nullable=True)
|
||||
created_by = Column(String(50), nullable=True, index=True)
|
||||
|
||||
submitted_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
status_changed_at = Column(DateTime, nullable=True)
|
||||
status_changed_by = Column(String(50), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_drone_status_takeoff", "status", "estimated_takeoff_at"),
|
||||
)
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return self.applicant_notes
|
||||
@@ -7,10 +7,13 @@ from app.db.session import Base
|
||||
class EntityType(str, PyEnum):
|
||||
"""Entity types that can have journal entries"""
|
||||
PPR = "PPR"
|
||||
DRONE_REQUEST = "DRONE_REQUEST"
|
||||
LOCAL_FLIGHT = "LOCAL_FLIGHT"
|
||||
ARRIVAL = "ARRIVAL"
|
||||
DEPARTURE = "DEPARTURE"
|
||||
OVERFLIGHT = "OVERFLIGHT"
|
||||
CIRCUIT = "CIRCUIT"
|
||||
USER = "USER"
|
||||
|
||||
|
||||
class JournalEntry(Base):
|
||||
|
||||
@@ -4,6 +4,11 @@ from enum import Enum
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class LocalFlightType(str, Enum):
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUITS = "CIRCUITS"
|
||||
@@ -12,7 +17,13 @@ class LocalFlightType(str, Enum):
|
||||
|
||||
class LocalFlightStatus(str, Enum):
|
||||
BOOKED_OUT = "BOOKED_OUT"
|
||||
GROUND = "GROUND"
|
||||
DEPARTED = "DEPARTED"
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUIT = "CIRCUIT"
|
||||
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
|
||||
CIRCUIT_BASE = "CIRCUIT_BASE"
|
||||
CIRCUIT_FINAL = "CIRCUIT_FINAL"
|
||||
LANDED = "LANDED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
@@ -24,7 +35,7 @@ class LocalFlight(Base):
|
||||
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
|
||||
pob = Column(Integer, nullable=True) # Persons on board may be unknown for post-event logging
|
||||
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
|
||||
@@ -32,7 +43,11 @@ class LocalFlight(Base):
|
||||
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
|
||||
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
|
||||
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
|
||||
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
|
||||
landed_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
|
||||
pilot_email = Column(String(128), nullable=True) # For public submissions
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, Enum as SQLEnum, func, Index
|
||||
from enum import Enum
|
||||
from app.db.session import Base
|
||||
|
||||
class MovementType(str, Enum):
|
||||
TAKEOFF = "TAKEOFF" # Aircraft becomes airborne
|
||||
LANDING = "LANDING" # Aircraft touches down
|
||||
OVERFLIGHT = "OVERFLIGHT" # Aircraft passes through airspace (e.g., on call or QSY)
|
||||
GO_AROUND = "GO_AROUND" # Aircraft aborts landing and goes around
|
||||
TOUCH_AND_GO = "TOUCH_AND_GO" # Aircraft lands and immediately takes off again
|
||||
|
||||
class Movement(Base):
|
||||
__tablename__ = "movements"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
movement_type = Column(SQLEnum(MovementType), nullable=False, index=True)
|
||||
aircraft_registration = Column(String(16), nullable=False, index=True)
|
||||
aircraft_type = Column(String(32), nullable=True)
|
||||
callsign = Column(String(16), nullable=True)
|
||||
timestamp = Column(DateTime, nullable=False, index=True) # Exact time of movement
|
||||
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT
|
||||
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the associated flight record
|
||||
to_location = Column(String(64), nullable=True) # Destination (TO) - populated based on movement type
|
||||
from_location = Column(String(64), nullable=True) # Origin (FROM) - populated based on movement type
|
||||
runway = Column(String(10), nullable=True) # Runway used (e.g., "10", "28", "04", "22")
|
||||
wind = Column(String(20), nullable=True) # Wind speed/direction (e.g., "280/25")
|
||||
pressure_setting = Column(String(20), nullable=True) # Pressure setting (e.g., "QNH1024", "QFE1013")
|
||||
created_by = Column(String(16), nullable=True, index=True) # User who triggered the movement
|
||||
ip_address = Column(String(45), nullable=True) # For audit
|
||||
notes = Column(String(255), nullable=True) # Optional context (e.g., runway used)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
|
||||
# Composite index for efficient queries
|
||||
__table_args__ = (
|
||||
Index('idx_movement_lookup', 'entity_type', 'entity_id'),
|
||||
Index('idx_movement_time', 'timestamp', 'movement_type'),
|
||||
)
|
||||
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
|
||||
LANDED = "LANDED"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
@@ -42,6 +43,8 @@ class PPRRecord(Base):
|
||||
departed_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
acknowledged_dt = Column(DateTime, nullable=True)
|
||||
acknowledged_by = Column(String(50), nullable=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())
|
||||
|
||||
@@ -89,3 +92,15 @@ class Aircraft(Base):
|
||||
clean_reg = Column(String(25), nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
|
||||
|
||||
class UserAircraft(Base):
|
||||
__tablename__ = "user_aircraft"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
registration = Column(String(25), nullable=False, unique=True, index=True)
|
||||
type_code = Column(String(30), nullable=False)
|
||||
clean_reg = Column(String(25), nullable=False, index=True)
|
||||
created_by = Column(String(16), nullable=False, index=True)
|
||||
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())
|
||||
|
||||
@@ -6,10 +6,23 @@ from enum import Enum
|
||||
|
||||
class ArrivalStatus(str, Enum):
|
||||
BOOKED_IN = "BOOKED_IN"
|
||||
INBOUND = "INBOUND"
|
||||
LANDED = "LANDED"
|
||||
GROUND = "GROUND"
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUIT = "CIRCUIT"
|
||||
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
|
||||
CIRCUIT_BASE = "CIRCUIT_BASE"
|
||||
CIRCUIT_FINAL = "CIRCUIT_FINAL"
|
||||
ARRIVED = "ARRIVED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class ArrivalBase(BaseModel):
|
||||
registration: str
|
||||
type: Optional[str] = None
|
||||
@@ -39,6 +52,7 @@ class ArrivalBase(BaseModel):
|
||||
|
||||
class ArrivalCreate(ArrivalBase):
|
||||
eta: Optional[datetime] = None
|
||||
submitted_via: Optional[SubmissionSource] = SubmissionSource.ADMIN
|
||||
|
||||
|
||||
class ArrivalUpdate(BaseModel):
|
||||
@@ -47,6 +61,10 @@ class ArrivalUpdate(BaseModel):
|
||||
callsign: Optional[str] = None
|
||||
pob: Optional[int] = None
|
||||
in_from: Optional[str] = None
|
||||
status: Optional[ArrivalStatus] = None
|
||||
eta: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
arrived_dt: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -61,8 +79,11 @@ class Arrival(ArrivalBase):
|
||||
created_dt: datetime
|
||||
eta: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
arrived_dt: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
updated_at: datetime
|
||||
submitted_via: Optional[SubmissionSource] = None
|
||||
pilot_email: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Optional
|
||||
|
||||
|
||||
class CircuitBase(BaseModel):
|
||||
local_flight_id: int
|
||||
local_flight_id: Optional[int] = None
|
||||
arrival_id: Optional[int] = None
|
||||
circuit_timestamp: datetime
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,16 @@ from enum import Enum
|
||||
|
||||
class DepartureStatus(str, Enum):
|
||||
BOOKED_OUT = "BOOKED_OUT"
|
||||
GROUND = "GROUND"
|
||||
DEPARTED = "DEPARTED"
|
||||
LOCAL = "LOCAL"
|
||||
CANCELLED = "CANCELLED"
|
||||
PENDING = "PENDING"
|
||||
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class DepartureBase(BaseModel):
|
||||
@@ -39,7 +47,7 @@ class DepartureBase(BaseModel):
|
||||
|
||||
|
||||
class DepartureCreate(DepartureBase):
|
||||
pass
|
||||
arrival_id: Optional[int] = None
|
||||
|
||||
|
||||
class DepartureUpdate(BaseModel):
|
||||
@@ -48,7 +56,11 @@ class DepartureUpdate(BaseModel):
|
||||
callsign: Optional[str] = None
|
||||
pob: Optional[int] = None
|
||||
out_to: Optional[str] = None
|
||||
status: Optional[DepartureStatus] = None
|
||||
etd: Optional[datetime] = None
|
||||
contact_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -62,4 +74,13 @@ class Departure(DepartureBase):
|
||||
status: DepartureStatus
|
||||
created_dt: datetime
|
||||
etd: Optional[datetime] = None
|
||||
contact_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
updated_at: datetime
|
||||
submitted_via: Optional[SubmissionSource] = None
|
||||
pilot_email: Optional[str] = None
|
||||
arrival_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequestBase(BaseModel):
|
||||
operator_name: str = Field(..., max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
maximum_elevation_ft_amsl: int = Field(..., ge=0)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: float = Field(..., ge=-90, le=90)
|
||||
location_longitude: float = Field(..., ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
notes: Optional[str] = None
|
||||
estimated_takeoff_at: datetime
|
||||
estimated_completion_at: datetime
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
|
||||
@validator("operator_name")
|
||||
def validate_operator_name(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Operator name is required")
|
||||
return value
|
||||
|
||||
@validator("location_inside_frz", pre=True)
|
||||
def parse_inside_frz(cls, value):
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"yes", "true", "1", "y"}:
|
||||
return True
|
||||
if normalized in {"no", "false", "0", "n"}:
|
||||
return False
|
||||
return value
|
||||
|
||||
|
||||
class DroneRequestCreate(DroneRequestBase):
|
||||
pass
|
||||
|
||||
|
||||
class DroneRequestUpdate(BaseModel):
|
||||
operator_name: Optional[str] = Field(None, max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_takeoff_at: Optional[datetime] = None
|
||||
estimated_completion_at: Optional[datetime] = None
|
||||
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
operator_comments: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestStatusUpdate(BaseModel):
|
||||
status: DroneRequestStatus
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestComment(BaseModel):
|
||||
comment: str = Field(..., min_length=1)
|
||||
email_applicant: bool = True
|
||||
|
||||
|
||||
class DroneRequest(DroneRequestBase):
|
||||
id: int
|
||||
reference_number: str
|
||||
status: DroneRequestStatus
|
||||
operator_comments: Optional[str] = None
|
||||
submitted_via: str
|
||||
submitted_ip: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_at: datetime
|
||||
updated_at: datetime
|
||||
status_changed_at: Optional[datetime] = None
|
||||
status_changed_by: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -12,16 +12,27 @@ class LocalFlightType(str, Enum):
|
||||
|
||||
class LocalFlightStatus(str, Enum):
|
||||
BOOKED_OUT = "BOOKED_OUT"
|
||||
GROUND = "GROUND"
|
||||
DEPARTED = "DEPARTED"
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUIT = "CIRCUIT"
|
||||
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
|
||||
CIRCUIT_BASE = "CIRCUIT_BASE"
|
||||
CIRCUIT_FINAL = "CIRCUIT_FINAL"
|
||||
LANDED = "LANDED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PUBLIC = "PUBLIC"
|
||||
|
||||
|
||||
class LocalFlightBase(BaseModel):
|
||||
registration: str
|
||||
type: Optional[str] = None # Aircraft type - optional, can be looked up later
|
||||
callsign: Optional[str] = None
|
||||
pob: int
|
||||
pob: Optional[int] = None
|
||||
flight_type: LocalFlightType
|
||||
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||
@@ -61,7 +72,10 @@ class LocalFlightUpdate(BaseModel):
|
||||
duration: Optional[int] = None
|
||||
status: Optional[LocalFlightStatus] = None
|
||||
etd: Optional[datetime] = None
|
||||
contact_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
circuits: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -76,11 +90,15 @@ class LocalFlightInDBBase(LocalFlightBase):
|
||||
status: LocalFlightStatus
|
||||
created_dt: datetime
|
||||
etd: Optional[datetime] = None
|
||||
contact_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
circuits: Optional[int] = None
|
||||
created_by: Optional[str] = None
|
||||
updated_at: datetime
|
||||
submitted_via: Optional[SubmissionSource] = None
|
||||
pilot_email: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date, datetime
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
class MovementBase(BaseModel):
|
||||
movement_type: MovementType
|
||||
aircraft_registration: str
|
||||
aircraft_type: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
timestamp: datetime
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
to_location: Optional[str] = None
|
||||
from_location: Optional[str] = None
|
||||
runway: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
pressure_setting: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MovementCreate(MovementBase):
|
||||
pass
|
||||
|
||||
|
||||
class BulkMovementLog(BaseModel):
|
||||
flight_kind: str
|
||||
movement_date: date
|
||||
movement_time: Optional[str] = None
|
||||
takeoff_time: Optional[str] = None
|
||||
landing_time: Optional[str] = None
|
||||
contact_time: Optional[str] = None
|
||||
qsy_time: Optional[str] = None
|
||||
aircraft_registration: str
|
||||
aircraft_type: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
from_location: Optional[str] = None
|
||||
to_location: Optional[str] = None
|
||||
pob: Optional[int] = None
|
||||
local_nature: Optional[str] = None
|
||||
circuits: Optional[int] = None
|
||||
runway: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
pressure_setting: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
ppr_id: Optional[int] = None
|
||||
movement_id: Optional[int] = None
|
||||
|
||||
|
||||
class BulkMovementContext(BaseModel):
|
||||
pprs: List[dict]
|
||||
local_flights: List[dict] = Field(default_factory=list)
|
||||
movements: List[dict]
|
||||
suggested: dict
|
||||
|
||||
|
||||
class Movement(MovementBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BulkMovementResult(BaseModel):
|
||||
action: str
|
||||
movement: Movement
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
message: str
|
||||
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
|
||||
LANDED = "LANDED"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
@@ -87,6 +88,8 @@ class PPRInDBBase(PPRBase):
|
||||
departed_dt: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_dt: datetime
|
||||
acknowledged_dt: Optional[datetime] = None
|
||||
acknowledged_by: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -215,3 +218,23 @@ class Aircraft(AircraftBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# User Aircraft schemas
|
||||
class UserAircraftBase(BaseModel):
|
||||
registration: str
|
||||
type_code: str
|
||||
clean_reg: str
|
||||
created_by: str
|
||||
|
||||
|
||||
class UserAircraft(UserAircraftBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserAircraftCreate(BaseModel):
|
||||
registration: str
|
||||
type_code: str
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
from pydantic import BaseModel, validator, EmailStr
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LocalFlightType(str, Enum):
|
||||
LOCAL = "LOCAL"
|
||||
CIRCUITS = "CIRCUITS"
|
||||
DEPARTURE = "DEPARTURE"
|
||||
|
||||
|
||||
class PublicLocalFlightCreate(BaseModel):
|
||||
"""Schema for public local flight booking"""
|
||||
registration: str
|
||||
type: Optional[str] = None # Aircraft type - optional
|
||||
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
|
||||
pilot_email: Optional[str] = None # Pilot's email for contact (optional)
|
||||
pilot_name: Optional[str] = None # Pilot's name
|
||||
|
||||
@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('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
|
||||
|
||||
@validator('pilot_email', pre=True, always=False)
|
||||
def validate_pilot_email(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
class PublicCircuitCreate(BaseModel):
|
||||
"""Schema for public circuit (touch and go) recording"""
|
||||
local_flight_id: int
|
||||
circuit_timestamp: datetime
|
||||
pilot_email: Optional[str] = None
|
||||
|
||||
@validator('pilot_email', pre=True, always=False)
|
||||
def validate_pilot_email(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
class PublicDepartureCreate(BaseModel):
|
||||
"""Schema for public departure booking"""
|
||||
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
|
||||
pilot_email: Optional[str] = None
|
||||
pilot_name: 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
|
||||
|
||||
@validator('pilot_email', pre=True, always=False)
|
||||
def validate_pilot_email(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
class PublicArrivalCreate(BaseModel):
|
||||
"""Schema for public arrival booking"""
|
||||
registration: str
|
||||
type: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
pob: int
|
||||
in_from: str
|
||||
eta: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
pilot_email: Optional[str] = None
|
||||
pilot_name: 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
|
||||
|
||||
@validator('pilot_email', pre=True, always=False)
|
||||
def validate_pilot_email(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
return v.strip().lower()
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Approved</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #1f7a4d; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Approved</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">Your drone flight request has been approved.</p>
|
||||
|
||||
{% if message %}
|
||||
<div style="border-left: 5px solid #3498db; background: #eef7ff; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0;"><strong>Airport comment:</strong> {{ message }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> you must call the tower approximately 20 minutes before commencing flight. Do not commence unless you have made this pre-flight call and can comply with any tower instructions given at the time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies or phone calls.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Submitted</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Submitted</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">We have received your drone flight request. We will email you when the approval status changes or if we need more information.</p>
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> if you have not received approval on the day of flight, you must contact the tower to chase your request before commencing flight, and at least 20 minutes before your planned takeoff time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Update</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Update</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">{{ message }}</p>
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> if you have not received approval on the day of flight, you must contact the tower to chase your request before commencing flight, and at least 20 minutes before your planned takeoff time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -174,6 +174,12 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Generating QR Code"
|
||||
echo "========================================="
|
||||
python3 /app/generate_qr.py
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Starting Application Server"
|
||||
@@ -182,8 +188,8 @@ 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}
|
||||
echo "Starting in PRODUCTION mode with a single worker for in-process WebSocket broadcasts..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
else
|
||||
echo "Starting in DEVELOPMENT mode with auto-reload..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate booking QR code at container startup"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
def generate_booking_qr():
|
||||
"""Generate QR code for the booking page"""
|
||||
# Get base URL from environment, default to localhost
|
||||
base_url = os.environ.get('BASE_URL', 'http://localhost')
|
||||
booking_url = f"{base_url}/book"
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
output_dir = '/web/assets'
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_file = f'{output_dir}/booking-qr.png'
|
||||
|
||||
try:
|
||||
# Generate QR code using qrencode
|
||||
subprocess.run(
|
||||
['qrencode', '-o', output_file, '-s', '5', booking_url],
|
||||
check=True,
|
||||
capture_output=True
|
||||
)
|
||||
print(f"✓ Generated booking QR code: {output_file}")
|
||||
print(f" URL: {booking_url}")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"✗ Failed to generate QR code: {e.stderr.decode()}", file=sys.stderr)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print("✗ qrencode not found. Install with: apt-get install qrencode", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = generate_booking_qr()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,14 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
pythonpath = .
|
||||
addopts = -q
|
||||
filterwarnings =
|
||||
ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic\._internal\._config
|
||||
ignore:Pydantic V1 style `@validator` validators are deprecated:DeprecationWarning:app\.schemas\..*
|
||||
ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:app\.crud\..*
|
||||
ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:pydantic\.main
|
||||
ignore:The ``declarative_base\(\)`` function is now available as sqlalchemy\.orm\.declarative_base\(\):sqlalchemy.exc.MovedIn20Warning:app\.db\.session
|
||||
ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib\.utils
|
||||
ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:app\.main
|
||||
ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:fastapi\.applications
|
||||
@@ -12,8 +12,8 @@ email-validator==2.1.0
|
||||
pydantic[email]==2.5.0
|
||||
pydantic-settings==2.0.3
|
||||
pytest==7.4.3
|
||||
pytest-cov==4.1.0
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
aiosmtplib==3.0.1
|
||||
jinja2==3.1.2
|
||||
@@ -0,0 +1,199 @@
|
||||
# Backend API Test Guide
|
||||
|
||||
This directory contains the backend API test suite. The tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database. The goal is to cover the business-critical API behaviour without relying on MySQL, SMTP, or a running browser.
|
||||
|
||||
## How To Run
|
||||
|
||||
From `backend/`:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
From the project root with Docker Compose running:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest
|
||||
docker compose exec api pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Shared Fixtures
|
||||
|
||||
### `conftest.py`
|
||||
|
||||
Sets up the test harness used by every module.
|
||||
|
||||
What it does:
|
||||
- Provides safe default environment variables before the app imports settings.
|
||||
- Creates an in-memory SQLite database and overrides FastAPI's `get_db` dependency.
|
||||
- Recreates all tables for every test so tests cannot leak state into each other.
|
||||
- Patches SQLite primary-key handling for models that use `BigInteger` ids in production.
|
||||
- Provides `client` for unauthenticated requests and `auth_client` for administrator/operator requests.
|
||||
- Provides reusable PPR payload and factory fixtures.
|
||||
|
||||
Why it exists:
|
||||
- Keeps API tests fast, deterministic, and independent from Docker MySQL data.
|
||||
- Lets tests exercise the real FastAPI routes, schemas, CRUD calls, and dependency overrides.
|
||||
|
||||
## Test Modules
|
||||
|
||||
### `test_app_health.py`
|
||||
|
||||
Covers the simplest application-level endpoints.
|
||||
|
||||
What it tests:
|
||||
- `/` returns API metadata.
|
||||
- `/health` reports a healthy application and database connection.
|
||||
|
||||
Why it matters:
|
||||
- These tests catch broken app imports, router setup problems, and database dependency regressions early.
|
||||
|
||||
### `test_auth_api.py`
|
||||
|
||||
Covers authentication and admin user-management routes.
|
||||
|
||||
What it tests:
|
||||
- Login rejects invalid credentials.
|
||||
- Login returns a bearer token for a valid user.
|
||||
- Admin users can create, list, update, and change passwords for users.
|
||||
- Duplicate users and missing users return the expected errors.
|
||||
|
||||
Why it matters:
|
||||
- Auth is the gatekeeper for most operational endpoints.
|
||||
- The admin user flow is also a good end-to-end check of password hashing, token creation, CRUD, and journal side effects.
|
||||
|
||||
### `test_pprs_api.py`
|
||||
|
||||
Covers the core PPR lifecycle.
|
||||
|
||||
What it tests:
|
||||
- PPR routes require authentication where appropriate.
|
||||
- Authenticated users can create, read, update, patch, acknowledge, status-update, soft-delete, and audit PPRs.
|
||||
- List filters work for status, dates, skip, and limit.
|
||||
- Public PPR creation sends email and generates secure edit tokens.
|
||||
- Public edit and cancel token flows work and reject invalid or processed requests.
|
||||
- Activation creates an arrival and pending departure.
|
||||
- Missing PPRs return 404.
|
||||
- Invalid payloads return validation errors.
|
||||
|
||||
Why it matters:
|
||||
- PPRs are the central workflow in the system.
|
||||
- These tests protect the operational state transitions that drive tower/admin views and audit history.
|
||||
|
||||
### `test_public_api.py`
|
||||
|
||||
Covers public read-only board and lookup endpoints.
|
||||
|
||||
What it tests:
|
||||
- Public arrivals and departures start empty.
|
||||
- Today's PPRs, local flights, arrivals, and departures appear on public boards.
|
||||
- Old or cancelled records are excluded.
|
||||
- Public airport and aircraft lookups return seeded records.
|
||||
- Short or invalid lookup queries return empty lists.
|
||||
|
||||
Why it matters:
|
||||
- Public boards and lookup helpers are user-facing and unauthenticated.
|
||||
- These tests check that the public API exposes useful operational information without requiring login.
|
||||
|
||||
### `test_flight_strip_apis.py`
|
||||
|
||||
Covers authenticated flight-strip style CRUD endpoints.
|
||||
|
||||
What it tests:
|
||||
- Arrival lifecycle: create, list/filter, read, update, land, cancel, and not-found paths.
|
||||
- Landing an arrival promotes a linked pending departure.
|
||||
- Departure lifecycle: create, list/filter, update, takeoff/departure status, cancel, and not-found paths.
|
||||
- Local flight lifecycle: create, list/filter, update, depart, land, special lists, cancel, and not-found paths.
|
||||
- Overflight lifecycle: create, active/today lists, list/filter, update, mark inactive/QSY, cancel, and not-found paths.
|
||||
- Movement records are created for real takeoff, landing, touch-and-go, and overflight events where relevant.
|
||||
|
||||
Why it matters:
|
||||
- These endpoints represent day-to-day tower strip operations.
|
||||
- They also exercise important CRUD side effects: status timestamps, movements, linked departures, and journal entries.
|
||||
|
||||
### `test_circuits_api.py`
|
||||
|
||||
Covers circuit/touch-and-go records.
|
||||
|
||||
What it tests:
|
||||
- Circuits can be recorded for local flights.
|
||||
- Circuits can be recorded for arrivals.
|
||||
- Circuit list, lookup-by-flight, lookup-by-arrival, update, and delete work.
|
||||
- Invalid circuit creation requests are rejected when neither or both parent ids are supplied.
|
||||
- Missing circuits return 404.
|
||||
- Recording a circuit creates a touch-and-go movement.
|
||||
|
||||
Why it matters:
|
||||
- Circuit traffic is a distinct operational pattern and feeds movement logging.
|
||||
- The parent-id validation prevents ambiguous audit/movement records.
|
||||
|
||||
### `test_movements_api.py`
|
||||
|
||||
Covers movement listing, context lookup, and bulk paper-strip logging.
|
||||
|
||||
What it tests:
|
||||
- Movement list filters and single-record reads.
|
||||
- Bulk movement context suggests matching PPRs and existing movements.
|
||||
- Bulk logging can create and update PPR-linked arrivals.
|
||||
- Bulk logging can create unmatched arrival and departure records.
|
||||
- Bulk logging handles local flight strips with takeoff, landing, duration, and circuits.
|
||||
- Bulk logging handles overflight strips and updates existing overflight records.
|
||||
- Invalid bulk-log requests return helpful 400 errors.
|
||||
|
||||
Why it matters:
|
||||
- Bulk movement logging is one of the densest workflows in the API.
|
||||
- These tests protect the behaviour that translates paper-strip data into PPR, arrival, departure, local flight, overflight, movement, and journal records.
|
||||
|
||||
### `test_drone_requests_api.py`
|
||||
|
||||
Covers drone flight request workflows.
|
||||
|
||||
What it tests:
|
||||
- Public drone request creation generates references/tokens and sends confirmation email.
|
||||
- Public edit and cancel token flows work.
|
||||
- Processed drone requests cannot be edited or cancelled publicly.
|
||||
- Authenticated users can list, read, update, status-update, comment on, and audit drone requests.
|
||||
- Missing records and invalid payloads return expected errors.
|
||||
|
||||
Why it matters:
|
||||
- Drone requests are a newer workflow with public and authenticated surfaces.
|
||||
- The tests protect email notification, public token, status, comment, and journal behaviour.
|
||||
|
||||
### `test_public_book_api.py`
|
||||
|
||||
Covers the optional public booking portal.
|
||||
|
||||
What it tests:
|
||||
- Public booking rejects requests when disabled.
|
||||
- Public local flight booking creates a public-submitted local flight.
|
||||
- Public circuit recording creates a circuit and touch-and-go movement.
|
||||
- Public departure and arrival booking create public-submitted records with pilot emails.
|
||||
- Invalid public booking payloads return validation errors.
|
||||
|
||||
Why it matters:
|
||||
- Public booking is controlled by configuration and should be safe to disable.
|
||||
- When enabled, it creates operational records without authentication, so validation and submitted-via metadata matter.
|
||||
|
||||
### `test_journal_api.py`
|
||||
|
||||
Covers generic audit/journal endpoints.
|
||||
|
||||
What it tests:
|
||||
- Journal search filters by date, entity type, entity id, and user.
|
||||
- Invalid entity types are rejected.
|
||||
- User journal and entity journal endpoints return entries and summary counts.
|
||||
|
||||
Why it matters:
|
||||
- The journal is the audit trail across PPRs, flights, users, drone requests, and movements.
|
||||
- These tests make sure audit entries remain queryable as the system grows.
|
||||
|
||||
## Current Scope
|
||||
|
||||
The suite intentionally focuses on API behaviour, local WebSocket broadcast behaviour, and database side effects. It does not deeply test:
|
||||
- Full browser WebSocket lifecycle.
|
||||
- Real SMTP delivery.
|
||||
- Browser UI behaviour.
|
||||
- Every branch of low-level validators or helper functions.
|
||||
|
||||
Those areas are better handled with focused unit tests or E2E tests later.
|
||||
@@ -0,0 +1,150 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import BigInteger, Integer, create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
os.environ.setdefault("DB_USER", "test_user")
|
||||
os.environ.setdefault("DB_PASSWORD", "test_password")
|
||||
os.environ.setdefault("DB_NAME", "test_db")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("MAIL_HOST", "localhost")
|
||||
os.environ.setdefault("MAIL_USERNAME", "test")
|
||||
os.environ.setdefault("MAIL_PASSWORD", "test")
|
||||
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
||||
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
|
||||
os.environ.setdefault("BASE_URL", "http://testserver")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
from app.api import deps
|
||||
from app.db import session as db_session
|
||||
from app.db.session import Base
|
||||
from app.main import app
|
||||
from app.models.ppr import PPRRecord, PPRStatus, UserRole
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def use_sqlite_integer_primary_keys():
|
||||
for table in Base.metadata.tables.values():
|
||||
for column in table.columns:
|
||||
if column.primary_key and isinstance(column.type, BigInteger):
|
||||
column.type = Integer()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def db_session_override():
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
use_sqlite_integer_primary_keys()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
original_session_local = db_session.SessionLocal
|
||||
db_session.SessionLocal = TestingSessionLocal
|
||||
app.dependency_overrides[deps.get_db] = override_get_db
|
||||
|
||||
yield
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
db_session.SessionLocal = original_session_local
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client():
|
||||
test_user = SimpleNamespace(
|
||||
id=1,
|
||||
username="test-operator",
|
||||
role=UserRole.ADMINISTRATOR,
|
||||
is_active=1,
|
||||
)
|
||||
|
||||
app.dependency_overrides[deps.get_current_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_active_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_read_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_operator_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_admin_user] = lambda: test_user
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ppr_payload():
|
||||
return {
|
||||
"ac_reg": "g-test",
|
||||
"ac_type": "C172",
|
||||
"ac_call": "GTEST",
|
||||
"captain": "Test Pilot",
|
||||
"fuel": "AVGAS",
|
||||
"in_from": "EGLL",
|
||||
"eta": "2026-06-20T10:00:00",
|
||||
"pob_in": 2,
|
||||
"out_to": "EGKK",
|
||||
"etd": "2026-06-20T12:00:00",
|
||||
"pob_out": 2,
|
||||
"email": "pilot@example.com",
|
||||
"phone": "0123456789",
|
||||
"notes": "API test flight",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ppr_factory(db):
|
||||
def create_ppr(**overrides):
|
||||
values = {
|
||||
"status": PPRStatus.NEW,
|
||||
"ac_reg": "G-FACT",
|
||||
"ac_type": "PA28",
|
||||
"ac_call": "GFACT",
|
||||
"captain": "Factory Pilot",
|
||||
"fuel": "AVGAS",
|
||||
"in_from": "EGLL",
|
||||
"eta": datetime(2026, 6, 20, 10, 0),
|
||||
"pob_in": 2,
|
||||
"out_to": "EGKK",
|
||||
"etd": datetime(2026, 6, 20, 12, 0),
|
||||
"pob_out": 2,
|
||||
"email": None,
|
||||
"phone": None,
|
||||
"notes": "Factory test flight",
|
||||
"created_by": "factory",
|
||||
"public_token": "token-factory",
|
||||
}
|
||||
values.update(overrides)
|
||||
ppr = PPRRecord(**values)
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
db.refresh(ppr)
|
||||
return ppr
|
||||
|
||||
return create_ppr
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import ConnectionManager
|
||||
|
||||
|
||||
def test_root_returns_api_metadata(client):
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Airfield PPR API"
|
||||
assert response.json()["docs"] == "/docs"
|
||||
|
||||
|
||||
def test_health_check_reports_database_connection(client):
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
assert response.json()["database"] == "connected"
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self, fail_send=False):
|
||||
self.accepted = False
|
||||
self.fail_send = fail_send
|
||||
self.messages = []
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def send_text(self, message):
|
||||
if self.fail_send:
|
||||
raise RuntimeError("socket closed")
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_manager_broadcasts_to_active_connections_and_removes_dead_ones():
|
||||
manager = ConnectionManager()
|
||||
active_socket = FakeWebSocket()
|
||||
dead_socket = FakeWebSocket(fail_send=True)
|
||||
|
||||
await manager.connect(active_socket)
|
||||
await manager.connect(dead_socket)
|
||||
await manager.broadcast({"type": "ppr_updated", "id": 123})
|
||||
|
||||
assert active_socket.accepted is True
|
||||
assert dead_socket.accepted is True
|
||||
assert json.loads(active_socket.messages[0]) == {"type": "ppr_updated", "id": 123}
|
||||
assert manager.active_connections == [active_socket]
|
||||
@@ -0,0 +1,80 @@
|
||||
from app.crud.crud_user import user as crud_user
|
||||
from app.models.ppr import UserRole
|
||||
from app.schemas.ppr import UserCreate
|
||||
|
||||
|
||||
def test_login_rejects_invalid_credentials(client):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "missing", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Incorrect username or password"
|
||||
|
||||
|
||||
def test_login_returns_bearer_token_for_valid_user(client, db):
|
||||
crud_user.create(
|
||||
db,
|
||||
UserCreate(username="tower", password="secret-password", role=UserRole.OPERATOR),
|
||||
admin_user="test",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "tower", "password": "secret-password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["access_token"]
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["expires_in"] > 0
|
||||
|
||||
|
||||
def test_admin_user_crud_endpoints(auth_client):
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/auth/users",
|
||||
json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["username"] == "operator-one"
|
||||
assert created["role"] == "OPERATOR"
|
||||
|
||||
duplicate_response = auth_client.post(
|
||||
"/api/v1/auth/users",
|
||||
json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"},
|
||||
)
|
||||
|
||||
assert duplicate_response.status_code == 400
|
||||
|
||||
list_response = auth_client.get("/api/v1/auth/users")
|
||||
assert list_response.status_code == 200
|
||||
assert [user["username"] for user in list_response.json()] == ["operator-one"]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/auth/users/{created['id']}",
|
||||
json={"role": "READ_ONLY"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["role"] == "READ_ONLY"
|
||||
|
||||
password_response = auth_client.post(
|
||||
f"/api/v1/auth/users/{created['id']}/change-password",
|
||||
json={"password": "new-secret-password"},
|
||||
)
|
||||
assert password_response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_user_endpoints_return_not_found(auth_client):
|
||||
assert auth_client.get("/api/v1/auth/users/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/auth/users/404", json={"role": "OPERATOR"}).status_code == 404
|
||||
assert (
|
||||
auth_client.post(
|
||||
"/api/v1/auth/users/404/change-password",
|
||||
json={"password": "new-secret-password"},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
|
||||
|
||||
def test_circuit_lifecycle_for_local_flight(auth_client, db):
|
||||
flight = LocalFlight(
|
||||
registration="G-CIR1",
|
||||
type="C152",
|
||||
callsign="GCIR1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.CIRCUITS,
|
||||
status=LocalFlightStatus.CIRCUIT,
|
||||
created_by="test",
|
||||
)
|
||||
db.add(flight)
|
||||
db.commit()
|
||||
db.refresh(flight)
|
||||
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"local_flight_id": flight.id, "circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
circuit = create_response.json()
|
||||
assert circuit["local_flight_id"] == flight.id
|
||||
|
||||
assert auth_client.get(f"/api/v1/circuits/{circuit['id']}").status_code == 200
|
||||
assert auth_client.get("/api/v1/circuits/").json()[0]["id"] == circuit["id"]
|
||||
assert auth_client.get(f"/api/v1/circuits/flight/{flight.id}").json()[0]["id"] == circuit["id"]
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
assert movement.aircraft_registration == "G-CIR1"
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/circuits/{circuit['id']}",
|
||||
json={"circuit_timestamp": "2026-06-20T10:20:00"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["circuit_timestamp"] == "2026-06-20T10:20:00"
|
||||
|
||||
delete_response = auth_client.delete(f"/api/v1/circuits/{circuit['id']}")
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["detail"] == "Circuit record deleted"
|
||||
|
||||
|
||||
def test_circuit_lifecycle_for_arrival_and_error_paths(auth_client, db):
|
||||
arrival = Arrival(
|
||||
registration="G-CIR2",
|
||||
type="PA28",
|
||||
callsign="GCIR2",
|
||||
pob=2,
|
||||
in_from="EGLL",
|
||||
status="INBOUND",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
created_by="test",
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"arrival_id": arrival.id, "circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
circuit = create_response.json()
|
||||
assert circuit["arrival_id"] == arrival.id
|
||||
assert auth_client.get(f"/api/v1/circuits/arrival/{arrival.id}").json()[0]["id"] == circuit["id"]
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
assert movement.from_location == "EGLL"
|
||||
|
||||
missing_entity = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
both_entities = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={
|
||||
"local_flight_id": 1,
|
||||
"arrival_id": arrival.id,
|
||||
"circuit_timestamp": "2026-06-20T10:10:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_entity.status_code == 400
|
||||
assert both_entities.status_code == 400
|
||||
assert auth_client.get("/api/v1/circuits/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/circuits/404", json={"circuit_timestamp": "2026-06-20T10:20:00"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/circuits/404").status_code == 404
|
||||
@@ -0,0 +1,156 @@
|
||||
from app.core.frz import point_inside_swansea_frz
|
||||
from app.models.drone_request import DroneRequest
|
||||
|
||||
|
||||
def drone_payload(**overrides):
|
||||
payload = {
|
||||
"operator_name": "Rotor Ops",
|
||||
"operator_id": "OP-123",
|
||||
"flyer_name": "Remote Pilot",
|
||||
"flyer_id": "FLY-456",
|
||||
"email": "pilot@example.com",
|
||||
"phone": "0123456789",
|
||||
"flight_date": "2026-06-20",
|
||||
"estimated_takeoff_time": "10:00",
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-20T10:00:00",
|
||||
"estimated_completion_at": "2026-06-20T10:30:00",
|
||||
"maximum_elevation_ft_amsl": 250,
|
||||
"location_description": "North apron",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
"location_inside_frz": "no",
|
||||
"notes": "Survey flight",
|
||||
"prototype_overlay": {
|
||||
"airport_reference_point": {"lat": 0, "lng": 0},
|
||||
"frz_radius_metres": 1,
|
||||
},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = client.post("/api/v1/drone-requests/public", json=drone_payload())
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["reference_number"].startswith("DRN-")
|
||||
assert created["status"] == "NEW"
|
||||
assert created["location_inside_frz"] is True
|
||||
assert created["created_by"] == "public"
|
||||
assert len(sent_emails) == 1
|
||||
|
||||
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
|
||||
assert db_request.public_token
|
||||
|
||||
get_response = client.get(f"/api/v1/drone-requests/public/edit/{db_request.public_token}")
|
||||
patch_response = client.patch(
|
||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
||||
json={"operator_name": "Updated Rotor Ops", "notes": "Updated notes"},
|
||||
)
|
||||
cancel_response = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["operator_name"] == "Updated Rotor Ops"
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELED"
|
||||
assert len(sent_emails) == 2
|
||||
|
||||
blocked_patch = client.patch(
|
||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
||||
json={"operator_name": "Too Late"},
|
||||
)
|
||||
blocked_cancel = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}")
|
||||
|
||||
assert blocked_patch.status_code == 400
|
||||
assert blocked_cancel.status_code == 400
|
||||
assert client.get("/api/v1/drone-requests/public/edit/missing-token").status_code == 404
|
||||
assert client.patch("/api/v1/drone-requests/public/edit/missing-token", json={}).status_code == 404
|
||||
assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404
|
||||
|
||||
|
||||
def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = auth_client.post("/api/v1/drone-requests/public", json=drone_payload())
|
||||
created = create_response.json()
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/drone-requests/",
|
||||
params={"status": "NEW", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}")
|
||||
update_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}",
|
||||
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_amsl": 200},
|
||||
)
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}/status",
|
||||
json={"status": "APPROVED", "comment": "Approved below 200ft"},
|
||||
)
|
||||
comment_response = auth_client.post(
|
||||
f"/api/v1/drone-requests/{created['id']}/comments",
|
||||
json={"comment": "Call tower before launch", "email_applicant": True},
|
||||
)
|
||||
journal_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}/journal")
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert [request["id"] for request in list_response.json()] == [created["id"]]
|
||||
assert get_response.status_code == 200
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["maximum_elevation_ft_amsl"] == 200
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["status"] == "APPROVED"
|
||||
assert status_response.json()["operator_comments"] == "Approved below 200ft"
|
||||
assert comment_response.status_code == 200
|
||||
assert comment_response.json()["operator_comments"] == "Call tower before launch"
|
||||
assert journal_response.status_code == 200
|
||||
entries = [entry["entry"] for entry in journal_response.json()]
|
||||
assert any("Drone request" in entry and "created" in entry for entry in entries)
|
||||
assert any("Status changed from NEW to APPROVED" in entry for entry in entries)
|
||||
assert any("Comment added" in entry for entry in entries)
|
||||
assert len(sent_emails) == 3
|
||||
|
||||
|
||||
def test_drone_request_not_found_and_validation_paths(auth_client, client):
|
||||
invalid_response = client.post(
|
||||
"/api/v1/drone-requests/public",
|
||||
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_amsl=-1),
|
||||
)
|
||||
|
||||
assert invalid_response.status_code == 422
|
||||
assert auth_client.get("/api/v1/drone-requests/404").status_code == 404
|
||||
assert auth_client.patch("/api/v1/drone-requests/404", json={"operator_name": "Missing"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/drone-requests/404/status", json={"status": "APPROVED"}).status_code == 404
|
||||
assert (
|
||||
auth_client.post(
|
||||
"/api/v1/drone-requests/404/comments",
|
||||
json={"comment": "Missing", "email_applicant": False},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert auth_client.get("/api/v1/drone-requests/404/journal").status_code == 404
|
||||
|
||||
|
||||
def test_swansea_frz_runway_extensions_start_at_thresholds():
|
||||
assert point_inside_swansea_frz(51.626825, -4.037672) is True
|
||||
assert point_inside_swansea_frz(51.583775, -4.097928) is True
|
||||
assert point_inside_swansea_frz(51.603007, -4.025604) is True
|
||||
assert point_inside_swansea_frz(51.607593, -4.109996) is True
|
||||
assert point_inside_swansea_frz(51.68000, -4.06780) is False
|
||||
@@ -0,0 +1,283 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
|
||||
|
||||
def test_arrival_lifecycle_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-arr",
|
||||
"type": "DA40",
|
||||
"callsign": "GARR",
|
||||
"pob": 2,
|
||||
"in_from": "egll",
|
||||
"eta": "2026-06-20T09:30:00",
|
||||
"notes": "Inbound test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/arrivals/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-ARR"
|
||||
assert created["status"] == "INBOUND"
|
||||
|
||||
assert auth_client.get(f"/api/v1/arrivals/{created['id']}").status_code == 200
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/arrivals/",
|
||||
params={"status": "INBOUND", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [arrival["id"] for arrival in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/arrivals/{created['id']}",
|
||||
json={"notes": "Updated inbound", "callsign": "ARRIVE"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["notes"] == "Updated inbound"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/arrivals/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["landed_dt"] == "2026-06-20T10:00:00"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one()
|
||||
assert movement.movement_type == MovementType.LANDING
|
||||
assert movement.aircraft_registration == "G-ARR"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/arrivals/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/arrivals/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/arrivals/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/arrivals/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/arrivals/404").status_code == 404
|
||||
|
||||
|
||||
def test_landing_arrival_promotes_linked_pending_departure(auth_client, db):
|
||||
arrival = Arrival(
|
||||
registration="G-LINK",
|
||||
type="PA28",
|
||||
callsign="GLINK",
|
||||
pob=2,
|
||||
in_from="EGLL",
|
||||
status="INBOUND",
|
||||
eta=datetime(2026, 6, 20, 9, 30),
|
||||
created_by="test",
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
|
||||
departure = Departure(
|
||||
registration="G-LINK",
|
||||
type="PA28",
|
||||
callsign="GLINK",
|
||||
pob=2,
|
||||
out_to="EGKK",
|
||||
status=DepartureStatus.PENDING,
|
||||
arrival_id=arrival.id,
|
||||
created_by="test",
|
||||
)
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
|
||||
response = auth_client.patch(
|
||||
f"/api/v1/arrivals/{arrival.id}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"},
|
||||
)
|
||||
|
||||
db.refresh(departure)
|
||||
assert response.status_code == 200
|
||||
assert departure.status == DepartureStatus.BOOKED_OUT
|
||||
|
||||
|
||||
def test_departure_lifecycle_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-dep",
|
||||
"type": "SR22",
|
||||
"callsign": "GDEP",
|
||||
"pob": 2,
|
||||
"out_to": "egkk",
|
||||
"etd": "2026-06-20T11:00:00",
|
||||
"notes": "Outbound test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/departures/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-DEP"
|
||||
assert created["status"] == "GROUND"
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/departures/",
|
||||
params={"status": "GROUND", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [departure["id"] for departure in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/departures/{created['id']}",
|
||||
json={"notes": "Updated outbound", "callsign": "DEPART"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["notes"] == "Updated outbound"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/departures/{created['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T11:10:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["takeoff_dt"] == "2026-06-20T11:10:00"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "DEPARTURE").one()
|
||||
assert movement.movement_type == MovementType.TAKEOFF
|
||||
assert movement.to_location == "egkk"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/departures/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/departures/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/departures/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/departures/404/status", json={"status": "DEPARTED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/departures/404").status_code == 404
|
||||
|
||||
|
||||
def test_local_flight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-loc",
|
||||
"type": "C152",
|
||||
"callsign": "GLOC",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 45,
|
||||
"etd": "2026-06-20T10:00:00",
|
||||
"notes": "Local test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/local-flights/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-LOC"
|
||||
assert created["status"] == "GROUND"
|
||||
|
||||
filter_response = auth_client.get(
|
||||
"/api/v1/local-flights/",
|
||||
params={"status": "GROUND", "flight_type": "LOCAL", "date_from": "2026-06-20"},
|
||||
)
|
||||
assert filter_response.status_code == 200
|
||||
assert [flight["id"] for flight in filter_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/local-flights/{created['id']}",
|
||||
json={"notes": "Updated local", "duration": 60},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["duration"] == 60
|
||||
|
||||
departed_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{created['id']}/status",
|
||||
json={"status": "DEPARTED", "timestamp": "2026-06-20T10:05:00"},
|
||||
)
|
||||
landed_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:45:00"},
|
||||
)
|
||||
|
||||
assert departed_response.status_code == 200
|
||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T10:05:00"
|
||||
assert landed_response.status_code == 200
|
||||
assert landed_response.json()["landed_dt"] == "2026-06-20T10:45:00"
|
||||
|
||||
movement_types = {
|
||||
movement.movement_type
|
||||
for movement in db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all()
|
||||
}
|
||||
assert movement_types == {MovementType.TAKEOFF, MovementType.LANDING}
|
||||
|
||||
active_response = auth_client.get("/api/v1/local-flights/active/current")
|
||||
today_departures_response = auth_client.get("/api/v1/local-flights/today/departures")
|
||||
booked_out_response = auth_client.get("/api/v1/local-flights/today/booked-out")
|
||||
|
||||
assert active_response.status_code == 200
|
||||
assert today_departures_response.status_code == 200
|
||||
assert booked_out_response.status_code == 200
|
||||
assert booked_out_response.json()[0]["id"] == created["id"]
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/local-flights/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/local-flights/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/local-flights/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/local-flights/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/local-flights/404").status_code == 404
|
||||
|
||||
|
||||
def test_overflight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-ovr",
|
||||
"pob": 1,
|
||||
"type": "PA28",
|
||||
"departure_airfield": "egll",
|
||||
"destination_airfield": "egkk",
|
||||
"call_dt": "2026-06-20T09:00:00",
|
||||
"notes": "Overflight test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/overflights/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-OVR"
|
||||
assert created["departure_airfield"] == "EGLL"
|
||||
assert created["status"] == "ACTIVE"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "OVERFLIGHT").one()
|
||||
assert movement.movement_type == MovementType.OVERFLIGHT
|
||||
assert movement.from_location == "EGLL"
|
||||
assert movement.to_location == "EGKK"
|
||||
|
||||
active_response = auth_client.get("/api/v1/overflights/active/list")
|
||||
today_response = auth_client.get("/api/v1/overflights/today/list")
|
||||
assert active_response.status_code == 200
|
||||
assert active_response.json()[0]["id"] == created["id"]
|
||||
assert today_response.status_code == 200
|
||||
assert today_response.json()[0]["id"] == created["id"]
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/overflights/",
|
||||
params={"status": "ACTIVE", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [overflight["id"] for overflight in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/overflights/{created['id']}",
|
||||
json={"notes": "Updated overflight", "destination_airfield": "egcc"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["destination_airfield"] == "EGCC"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/overflights/{created['id']}/status",
|
||||
json={"status": "INACTIVE", "qsy_dt": "2026-06-20T09:20:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["qsy_dt"] == "2026-06-20T09:20:00"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/overflights/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/overflights/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/overflights/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/overflights/404/status", json={"status": "INACTIVE"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/overflights/404").status_code == 404
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.journal import EntityType
|
||||
from app.crud.crud_journal import journal
|
||||
|
||||
|
||||
def test_search_journal_filters_entries(auth_client, db):
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
matching = journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
10,
|
||||
"Matching PPR change",
|
||||
"tower",
|
||||
"127.0.0.1",
|
||||
)
|
||||
other = journal.log_change(
|
||||
db,
|
||||
EntityType.USER,
|
||||
20,
|
||||
"Other user change",
|
||||
"admin",
|
||||
"127.0.0.1",
|
||||
)
|
||||
matching.entry_dt = yesterday
|
||||
other.entry_dt = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
response = auth_client.get(
|
||||
"/api/v1/journal/search/all",
|
||||
params={
|
||||
"date_from": yesterday.date().isoformat(),
|
||||
"date_to": yesterday.date().isoformat(),
|
||||
"entity_type": "PPR",
|
||||
"entity_id": 10,
|
||||
"user": "tower",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
entries = response.json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["entry"] == "Matching PPR change"
|
||||
|
||||
|
||||
def test_search_journal_rejects_invalid_entity_type(auth_client):
|
||||
response = auth_client.get(
|
||||
"/api/v1/journal/search/all",
|
||||
params={"entity_type": "NOT_A_THING"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid entity_type" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_user_and_entity_journal(auth_client, db):
|
||||
journal.log_change(db, EntityType.PPR, 55, "PPR audit entry", "tower", None)
|
||||
|
||||
user_response = auth_client.get("/api/v1/journal/user/tower")
|
||||
entity_response = auth_client.get("/api/v1/journal/PPR/55")
|
||||
|
||||
assert user_response.status_code == 200
|
||||
assert user_response.json()[0]["entry"] == "PPR audit entry"
|
||||
assert entity_response.status_code == 200
|
||||
body = entity_response.json()
|
||||
assert body["entity_type"] == "PPR"
|
||||
assert body["entity_id"] == 55
|
||||
assert body["total_entries"] == 1
|
||||
assert body["entries"][0]["entry"] == "PPR audit entry"
|
||||
|
||||
|
||||
def test_get_entity_journal_rejects_invalid_entity_type(auth_client):
|
||||
response = auth_client.get("/api/v1/journal/NOPE/1")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid entity_type" in response.json()["detail"]
|
||||
@@ -1,42 +0,0 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.main import app
|
||||
from app.api.deps import get_db
|
||||
from app.db.session import Base
|
||||
|
||||
# Create test database
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_read_main():
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "Airfield PPR API" in response.json()["message"]
|
||||
|
||||
def test_health_check():
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
def test_get_public_arrivals():
|
||||
response = client.get("/api/v1/public/arrivals")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
|
||||
|
||||
def movement_payload(**overrides):
|
||||
payload = {
|
||||
"flight_kind": "ARRIVAL",
|
||||
"movement_date": "2026-06-20",
|
||||
"movement_time": "10:00",
|
||||
"aircraft_registration": "G-MOV1",
|
||||
"aircraft_type": "PA28",
|
||||
"callsign": "GMOV1",
|
||||
"from_location": "EGLL",
|
||||
"to_location": "EGKK",
|
||||
"pob": 2,
|
||||
"runway": "27",
|
||||
"wind": "270/10",
|
||||
"pressure_setting": "QNH1013",
|
||||
"notes": "Bulk movement",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_movement_list_get_and_context_for_ppr(auth_client, db):
|
||||
ppr = PPRRecord(
|
||||
status=PPRStatus.NEW,
|
||||
ac_reg="G-MOV1",
|
||||
ac_type="PA28",
|
||||
ac_call="GMOV1",
|
||||
captain="Movement Pilot",
|
||||
in_from="EGLL",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
pob_in=2,
|
||||
out_to="EGKK",
|
||||
etd=datetime(2026, 6, 20, 11, 0),
|
||||
created_by="test",
|
||||
public_token="movement-ppr",
|
||||
)
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
db.refresh(ppr)
|
||||
|
||||
bulk_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(ppr_id=ppr.id, landing_time="10:05"),
|
||||
)
|
||||
|
||||
assert bulk_response.status_code == 200
|
||||
result = bulk_response.json()
|
||||
assert result["action"] == "created"
|
||||
assert result["entity_type"] == "PPR"
|
||||
assert result["entity_id"] == ppr.id
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/movements/",
|
||||
params={
|
||||
"movement_type": "LANDING",
|
||||
"aircraft_registration": "MOV1",
|
||||
"date_from": "2026-06-20",
|
||||
"date_to": "2026-06-20",
|
||||
"entity_type": "PPR",
|
||||
},
|
||||
)
|
||||
get_response = auth_client.get(f"/api/v1/movements/{result['movement']['id']}")
|
||||
context_response = auth_client.get(
|
||||
"/api/v1/movements/bulk-context",
|
||||
params={
|
||||
"target_date": "2026-06-20",
|
||||
"aircraft_registration": "G-MOV1",
|
||||
"flight_kind": "ARRIVAL",
|
||||
},
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert [movement["id"] for movement in list_response.json()] == [result["movement"]["id"]]
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["aircraft_registration"] == "G-MOV1"
|
||||
assert context_response.status_code == 200
|
||||
context = context_response.json()
|
||||
assert context["pprs"][0]["id"] == ppr.id
|
||||
assert context["movements"][0]["id"] == result["movement"]["id"]
|
||||
assert context["suggested"]["source"] == "movement"
|
||||
|
||||
|
||||
def test_bulk_log_updates_existing_movement_and_creates_unmatched_arrival_departure(auth_client, db):
|
||||
arrival_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(aircraft_registration="G-NEW1", landing_time="10:00", from_location="EGBB"),
|
||||
)
|
||||
update_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
aircraft_registration="G-NEW1",
|
||||
landing_time="10:15",
|
||||
from_location="EGBB",
|
||||
notes="Updated movement",
|
||||
),
|
||||
)
|
||||
departure_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="DEPARTURE",
|
||||
aircraft_registration="G-NEW2",
|
||||
takeoff_time="11:00",
|
||||
to_location="EGCC",
|
||||
),
|
||||
)
|
||||
|
||||
assert arrival_response.status_code == 200
|
||||
assert arrival_response.json()["entity_type"] == "ARRIVAL"
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["action"] == "updated"
|
||||
assert update_response.json()["movement"]["timestamp"] == "2026-06-20T10:15:00"
|
||||
assert departure_response.status_code == 200
|
||||
assert departure_response.json()["entity_type"] == "DEPARTURE"
|
||||
|
||||
arrival = db.query(Arrival).filter(Arrival.registration == "G-NEW1").one()
|
||||
departure = db.query(Departure).filter(Departure.registration == "G-NEW2").one()
|
||||
assert arrival.status.value == "LANDED"
|
||||
assert departure.status.value == "DEPARTED"
|
||||
|
||||
|
||||
def test_bulk_log_local_and_overflight_branches(auth_client, db):
|
||||
local_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="LOCAL",
|
||||
aircraft_registration="G-LOCX",
|
||||
takeoff_time="09:00",
|
||||
landing_time="09:45",
|
||||
local_nature="CIRCUITS",
|
||||
circuits=3,
|
||||
),
|
||||
)
|
||||
overflight_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="OVERFLIGHT",
|
||||
aircraft_registration="G-OVRX",
|
||||
contact_time="12:00",
|
||||
qsy_time="12:15",
|
||||
from_location="EGLL",
|
||||
to_location="EGKK",
|
||||
),
|
||||
)
|
||||
overflight_update_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="OVERFLIGHT",
|
||||
aircraft_registration="G-OVRX",
|
||||
contact_time="12:05",
|
||||
qsy_time="12:20",
|
||||
from_location="EGLL",
|
||||
to_location="EGCC",
|
||||
),
|
||||
)
|
||||
|
||||
assert local_response.status_code == 200
|
||||
assert local_response.json()["entity_type"] == "LOCAL_FLIGHT"
|
||||
local = db.query(LocalFlight).filter(LocalFlight.registration == "G-LOCX").one()
|
||||
assert local.status == LocalFlightStatus.LANDED
|
||||
assert local.flight_type == LocalFlightType.CIRCUITS
|
||||
assert local.circuits == 3
|
||||
|
||||
local_movements = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all()
|
||||
assert {movement.movement_type for movement in local_movements} == {
|
||||
MovementType.TAKEOFF,
|
||||
MovementType.LANDING,
|
||||
}
|
||||
|
||||
assert overflight_response.status_code == 200
|
||||
assert overflight_response.json()["entity_type"] == "OVERFLIGHT"
|
||||
assert overflight_update_response.status_code == 200
|
||||
assert overflight_update_response.json()["action"] == "updated"
|
||||
overflight = db.query(Overflight).filter(Overflight.registration == "G-OVRX").one()
|
||||
assert overflight.status == OverflightStatus.INACTIVE
|
||||
assert overflight.destination_airfield == "EGCC"
|
||||
|
||||
|
||||
def test_movement_error_paths(auth_client):
|
||||
missing_registration = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(aircraft_registration=""),
|
||||
)
|
||||
invalid_kind = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(flight_kind="BALLOON"),
|
||||
)
|
||||
missing_time = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(movement_time=None, landing_time=None),
|
||||
)
|
||||
invalid_time = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(landing_time="not-time"),
|
||||
)
|
||||
bad_local_times = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="LOCAL",
|
||||
takeoff_time="11:00",
|
||||
landing_time="10:00",
|
||||
),
|
||||
)
|
||||
bad_context = auth_client.get(
|
||||
"/api/v1/movements/bulk-context",
|
||||
params={
|
||||
"target_date": "2026-06-20",
|
||||
"aircraft_registration": "G-BAD",
|
||||
"flight_kind": "BALLOON",
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_registration.status_code == 400
|
||||
assert invalid_kind.status_code == 400
|
||||
assert missing_time.status_code == 400
|
||||
assert invalid_time.status_code == 400
|
||||
assert bad_local_times.status_code == 400
|
||||
assert bad_context.status_code == 400
|
||||
assert auth_client.get("/api/v1/movements/404").status_code == 404
|
||||
@@ -0,0 +1,187 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.ppr import PPRRecord
|
||||
|
||||
|
||||
def test_ppr_routes_require_authentication(client):
|
||||
response = client.get("/api/v1/pprs/")
|
||||
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_authenticated_user_can_create_read_update_and_audit_ppr(auth_client, ppr_payload):
|
||||
create_response = auth_client.post("/api/v1/pprs/", json=ppr_payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["id"] > 0
|
||||
assert created["status"] == "NEW"
|
||||
assert created["ac_reg"] == "G-TEST"
|
||||
assert created["created_by"] == "test-operator"
|
||||
|
||||
read_response = auth_client.get(f"/api/v1/pprs/{created['id']}")
|
||||
|
||||
assert read_response.status_code == 200
|
||||
assert read_response.json()["ac_reg"] == "G-TEST"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"},
|
||||
)
|
||||
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["status"] == "LANDED"
|
||||
assert status_response.json()["landed_dt"] == "2026-06-20T10:30:00"
|
||||
|
||||
journal_response = auth_client.get(f"/api/v1/pprs/{created['id']}/journal")
|
||||
|
||||
assert journal_response.status_code == 200
|
||||
entries = [entry["entry"] for entry in journal_response.json()]
|
||||
assert any("PPR created for G-TEST" in entry for entry in entries)
|
||||
assert any("Status changed from NEW to LANDED" in entry for entry in entries)
|
||||
|
||||
|
||||
def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
|
||||
ppr_factory(
|
||||
ac_reg="G-NEW1",
|
||||
status="NEW",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
etd=datetime(2026, 6, 20, 12, 0),
|
||||
public_token="token-new",
|
||||
)
|
||||
ppr_factory(
|
||||
ac_reg="G-CAN1",
|
||||
status="CANCELED",
|
||||
eta=datetime(2026, 6, 21, 10, 0),
|
||||
etd=datetime(2026, 6, 21, 12, 0),
|
||||
public_token="token-canceled",
|
||||
)
|
||||
|
||||
status_response = auth_client.get("/api/v1/pprs/", params={"status": "NEW"})
|
||||
date_response = auth_client.get(
|
||||
"/api/v1/pprs/",
|
||||
params={"date_from": "2026-06-21", "date_to": "2026-06-21"},
|
||||
)
|
||||
limited_response = auth_client.get("/api/v1/pprs/", params={"skip": 1, "limit": 1})
|
||||
|
||||
assert status_response.status_code == 200
|
||||
assert [ppr["ac_reg"] for ppr in status_response.json()] == ["G-NEW1"]
|
||||
assert date_response.status_code == 200
|
||||
assert [ppr["ac_reg"] for ppr in date_response.json()] == ["G-CAN1"]
|
||||
assert limited_response.status_code == 200
|
||||
assert len(limited_response.json()) == 1
|
||||
|
||||
|
||||
def test_authenticated_user_can_put_patch_acknowledge_and_delete_ppr(auth_client, ppr_payload):
|
||||
created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json()
|
||||
|
||||
put_response = auth_client.put(
|
||||
f"/api/v1/pprs/{created['id']}",
|
||||
json={**ppr_payload, "captain": "Updated Pilot"},
|
||||
)
|
||||
patch_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}",
|
||||
json={"notes": "Updated by patch"},
|
||||
)
|
||||
acknowledge_response = auth_client.post(f"/api/v1/pprs/{created['id']}/acknowledge")
|
||||
delete_response = auth_client.delete(f"/api/v1/pprs/{created['id']}")
|
||||
|
||||
assert put_response.status_code == 200
|
||||
assert put_response.json()["captain"] == "Updated Pilot"
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["notes"] == "Updated by patch"
|
||||
assert acknowledge_response.status_code == 200
|
||||
assert acknowledge_response.json()["acknowledged_by"] == "test-operator"
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["status"] == "DELETED"
|
||||
|
||||
|
||||
def test_ppr_not_found_paths(auth_client):
|
||||
assert auth_client.get("/api/v1/pprs/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/pprs/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.post("/api/v1/pprs/404/acknowledge").status_code == 404
|
||||
assert auth_client.delete("/api/v1/pprs/404").status_code == 404
|
||||
assert auth_client.get("/api/v1/pprs/404/journal").status_code == 404
|
||||
assert auth_client.post("/api/v1/pprs/404/activate").status_code == 404
|
||||
|
||||
|
||||
def test_public_ppr_create_sends_email_and_generates_token(client, db, ppr_payload, monkeypatch):
|
||||
sent_email = {}
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_email.update(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.pprs.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = client.post("/api/v1/pprs/public", json=ppr_payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["created_by"] == "public"
|
||||
assert sent_email["to_email"] == "pilot@example.com"
|
||||
|
||||
db_ppr = db.query(PPRRecord).filter(PPRRecord.id == created["id"]).one()
|
||||
assert db_ppr.public_token
|
||||
|
||||
|
||||
def test_public_ppr_token_edit_and_cancel_paths(client, ppr_factory, db):
|
||||
ppr = ppr_factory(public_token="public-edit-token", email=None)
|
||||
|
||||
get_response = client.get("/api/v1/pprs/public/edit/public-edit-token")
|
||||
patch_response = client.patch(
|
||||
"/api/v1/pprs/public/edit/public-edit-token",
|
||||
json={"captain": "Public Editor"},
|
||||
)
|
||||
cancel_response = client.delete("/api/v1/pprs/public/cancel/public-edit-token")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["id"] == ppr.id
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["captain"] == "Public Editor"
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELED"
|
||||
|
||||
assert client.get("/api/v1/pprs/public/edit/public-edit-token").status_code == 400
|
||||
assert client.patch("/api/v1/pprs/public/edit/missing-token", json={}).status_code == 404
|
||||
assert client.delete("/api/v1/pprs/public/cancel/missing-token").status_code == 404
|
||||
|
||||
|
||||
def test_activate_ppr_creates_arrival_and_pending_departure(auth_client, ppr_factory, db):
|
||||
ppr = ppr_factory(public_token="activate-token")
|
||||
|
||||
response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["arrival_id"]
|
||||
assert body["departure_id"]
|
||||
|
||||
arrival = db.query(Arrival).filter(Arrival.id == body["arrival_id"]).one()
|
||||
departure = db.query(Departure).filter(Departure.id == body["departure_id"]).one()
|
||||
|
||||
assert arrival.registration == "G-FACT"
|
||||
assert arrival.in_from == "EGLL"
|
||||
assert departure.status == DepartureStatus.PENDING
|
||||
assert departure.arrival_id == arrival.id
|
||||
|
||||
|
||||
def test_activate_rejects_processed_ppr(auth_client, ppr_factory):
|
||||
ppr = ppr_factory(status="LANDED", public_token="processed-token")
|
||||
|
||||
response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "cannot be activated" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_ppr_payload_returns_validation_error(auth_client, ppr_payload):
|
||||
ppr_payload["pob_in"] = -1
|
||||
|
||||
response = auth_client.post("/api/v1/pprs/", json=ppr_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,241 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.arrival import Arrival, ArrivalStatus
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.ppr import Aircraft, Airport, PPRRecord, PPRStatus
|
||||
|
||||
|
||||
def test_public_arrivals_and_departures_start_empty(client):
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert arrivals.json() == []
|
||||
assert departures.status_code == 200
|
||||
assert departures.json() == []
|
||||
|
||||
|
||||
def test_public_boards_include_todays_flights(client, db):
|
||||
now = datetime.now().replace(microsecond=0)
|
||||
ppr = PPRRecord(
|
||||
status=PPRStatus.LANDED,
|
||||
ac_reg="G-PPR1",
|
||||
ac_type="PA28",
|
||||
ac_call="GPPR1",
|
||||
captain="Arriving Pilot",
|
||||
in_from="EGLL",
|
||||
eta=now,
|
||||
pob_in=2,
|
||||
out_to="EGKK",
|
||||
etd=now,
|
||||
pob_out=2,
|
||||
created_by="test",
|
||||
)
|
||||
local_flight = LocalFlight(
|
||||
registration="G-LOC1",
|
||||
type="C152",
|
||||
callsign="GLOC1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.BOOKED_OUT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
arrival = Arrival(
|
||||
registration="G-ARR1",
|
||||
type="DA40",
|
||||
callsign="GARR1",
|
||||
pob=3,
|
||||
in_from="EGBB",
|
||||
status=ArrivalStatus.INBOUND,
|
||||
created_dt=now,
|
||||
eta=now,
|
||||
)
|
||||
departure = Departure(
|
||||
registration="G-DEP1",
|
||||
type="SR22",
|
||||
callsign="GDEP1",
|
||||
pob=2,
|
||||
out_to="EGCC",
|
||||
status=DepartureStatus.BOOKED_OUT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
db.add_all([ppr, local_flight, arrival, departure])
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert {item.get("ac_reg") or item.get("registration") for item in arrivals.json()} == {
|
||||
"G-PPR1",
|
||||
"G-ARR1",
|
||||
}
|
||||
assert departures.status_code == 200
|
||||
assert {item["ac_reg"] for item in departures.json()} == {
|
||||
"G-PPR1",
|
||||
"G-LOC1",
|
||||
"G-DEP1",
|
||||
}
|
||||
|
||||
|
||||
def test_public_boards_include_current_local_flight_statuses(client, db):
|
||||
now = datetime.now().replace(microsecond=0)
|
||||
ground_local = LocalFlight(
|
||||
registration="G-GRND",
|
||||
type="C152",
|
||||
callsign="GGRND",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.GROUND,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
airborne_local = LocalFlight(
|
||||
registration="G-AIR1",
|
||||
type="PA28",
|
||||
callsign="GAIR1",
|
||||
pob=2,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.LOCAL,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
duration=45,
|
||||
)
|
||||
circuit_local = LocalFlight(
|
||||
registration="G-CCT1",
|
||||
type="C152",
|
||||
callsign="GCCT1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.CIRCUITS,
|
||||
status=LocalFlightStatus.CIRCUIT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
duration=30,
|
||||
)
|
||||
landed_local = LocalFlight(
|
||||
registration="G-LND1",
|
||||
type="C172",
|
||||
callsign="GLND1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
landed_dt=now,
|
||||
)
|
||||
db.add_all([ground_local, airborne_local, circuit_local, landed_local])
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert {
|
||||
item["ac_reg"] for item in arrivals.json() if item.get("isLocalFlight")
|
||||
} == {"G-AIR1", "G-CCT1"}
|
||||
|
||||
assert departures.status_code == 200
|
||||
assert {
|
||||
item["ac_reg"] for item in departures.json() if item.get("isLocalFlight")
|
||||
} == {"G-GRND"}
|
||||
|
||||
|
||||
def test_public_reference_lookups_return_seeded_records(client, db):
|
||||
db.add(
|
||||
Airport(
|
||||
icao="EGLL",
|
||||
iata="LHR",
|
||||
name="London Heathrow",
|
||||
country="United Kingdom",
|
||||
city="London",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Aircraft(
|
||||
registration="G-ABCD",
|
||||
type_code="PA28",
|
||||
clean_reg="GABCD",
|
||||
manufacturer_name="Piper",
|
||||
model="Cherokee",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
airport_response = client.get("/api/v1/airport/public/lookup/EGLL")
|
||||
aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-ABC")
|
||||
|
||||
assert airport_response.status_code == 200
|
||||
assert airport_response.json()[0]["icao"] == "EGLL"
|
||||
assert aircraft_response.status_code == 200
|
||||
assert aircraft_response.json()[0]["registration"] == "G-ABCD"
|
||||
|
||||
|
||||
def test_public_reference_lookups_return_empty_for_short_queries(client):
|
||||
airport_response = client.get("/api/v1/airport/public/lookup/E")
|
||||
aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-A")
|
||||
|
||||
assert airport_response.status_code == 200
|
||||
assert airport_response.json() == []
|
||||
assert aircraft_response.status_code == 200
|
||||
assert aircraft_response.json() == []
|
||||
|
||||
|
||||
def test_public_airport_lookup_searches_by_partial_name(client, db):
|
||||
db.add(
|
||||
Airport(
|
||||
icao="EGBB",
|
||||
iata="BHX",
|
||||
name="Birmingham Airport",
|
||||
country="United Kingdom",
|
||||
city="Birmingham",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/v1/airport/public/lookup/Birmingham")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["icao"] == "EGBB"
|
||||
|
||||
|
||||
def test_public_boards_exclude_old_and_cancelled_records(client, db):
|
||||
yesterday = datetime.now().replace(microsecond=0) - timedelta(days=1)
|
||||
db.add(
|
||||
PPRRecord(
|
||||
status=PPRStatus.CANCELED,
|
||||
ac_reg="G-OLD1",
|
||||
ac_type="PA28",
|
||||
captain="Old Pilot",
|
||||
in_from="EGLL",
|
||||
eta=yesterday,
|
||||
pob_in=1,
|
||||
out_to="EGKK",
|
||||
etd=yesterday,
|
||||
created_by="test",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
LocalFlight(
|
||||
registration="G-OLD2",
|
||||
type="C152",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.BOOKED_OUT,
|
||||
created_dt=yesterday,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert arrivals.json() == []
|
||||
assert departures.status_code == 200
|
||||
assert departures.json() == []
|
||||
@@ -0,0 +1,138 @@
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure
|
||||
from app.models.local_flight import LocalFlight
|
||||
from app.models.movement import Movement, MovementType
|
||||
|
||||
|
||||
def enable_public_booking(monkeypatch, enabled=True):
|
||||
monkeypatch.setattr("app.api.endpoints.public_book.settings.allow_public_booking", enabled)
|
||||
|
||||
|
||||
def test_public_booking_rejects_requests_when_disabled(client, monkeypatch):
|
||||
enable_public_booking(monkeypatch, enabled=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={
|
||||
"registration": "G-PUB1",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Public booking is currently disabled"
|
||||
|
||||
|
||||
def test_public_booking_creates_local_flight_and_circuit(client, db, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
flight_response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={
|
||||
"registration": "g-pub1",
|
||||
"type": "C152",
|
||||
"callsign": "GPUB1",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 30,
|
||||
"etd": "2026-06-20T10:00:00",
|
||||
"notes": "Public local",
|
||||
"pilot_email": " PILOT@EXAMPLE.COM ",
|
||||
},
|
||||
)
|
||||
|
||||
assert flight_response.status_code == 200
|
||||
flight = flight_response.json()
|
||||
assert flight["registration"] == "G-PUB1"
|
||||
assert flight["status"] == "BOOKED_OUT"
|
||||
assert flight["submitted_via"] == "PUBLIC"
|
||||
assert flight["pilot_email"] == "pilot@example.com"
|
||||
|
||||
circuit_response = client.post(
|
||||
"/api/v1/public-book/circuits",
|
||||
json={
|
||||
"local_flight_id": flight["id"],
|
||||
"circuit_timestamp": "2026-06-20T10:15:00",
|
||||
"pilot_email": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert circuit_response.status_code == 200
|
||||
assert circuit_response.json()["local_flight_id"] == flight["id"]
|
||||
|
||||
db_flight = db.query(LocalFlight).filter(LocalFlight.id == flight["id"]).one()
|
||||
assert db_flight.created_by == "PUBLIC_PILOT"
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
|
||||
|
||||
def test_public_booking_creates_departure_and_arrival(client, db, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
departure_response = client.post(
|
||||
"/api/v1/public-book/departures",
|
||||
json={
|
||||
"registration": "g-pub2",
|
||||
"type": "PA28",
|
||||
"callsign": "GPUB2",
|
||||
"pob": 2,
|
||||
"out_to": "egkk",
|
||||
"etd": "2026-06-20T11:00:00",
|
||||
"notes": "Public departure",
|
||||
"pilot_email": "depart@example.com",
|
||||
},
|
||||
)
|
||||
arrival_response = client.post(
|
||||
"/api/v1/public-book/arrivals",
|
||||
json={
|
||||
"registration": "g-pub3",
|
||||
"type": "DA40",
|
||||
"callsign": "GPUB3",
|
||||
"pob": 3,
|
||||
"in_from": "egll",
|
||||
"eta": "2026-06-20T12:00:00",
|
||||
"notes": "Public arrival",
|
||||
"pilot_email": "arrive@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert departure_response.status_code == 200
|
||||
departure = departure_response.json()
|
||||
assert departure["registration"] == "G-PUB2"
|
||||
assert departure["status"] == "BOOKED_OUT"
|
||||
assert departure["submitted_via"] == "PUBLIC"
|
||||
assert departure["pilot_email"] == "depart@example.com"
|
||||
|
||||
assert arrival_response.status_code == 200
|
||||
arrival = arrival_response.json()
|
||||
assert arrival["registration"] == "G-PUB3"
|
||||
assert arrival["status"] == "BOOKED_IN"
|
||||
assert arrival["submitted_via"] == "PUBLIC"
|
||||
assert arrival["pilot_email"] == "arrive@example.com"
|
||||
|
||||
db_departure = db.query(Departure).filter(Departure.id == departure["id"]).one()
|
||||
db_arrival = db.query(Arrival).filter(Arrival.id == arrival["id"]).one()
|
||||
assert db_departure.created_by == "PUBLIC_PILOT"
|
||||
assert db_arrival.created_by == "PUBLIC_PILOT"
|
||||
|
||||
|
||||
def test_public_booking_validates_payloads(client, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
local_response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={"registration": "", "pob": 0, "flight_type": "LOCAL"},
|
||||
)
|
||||
departure_response = client.post(
|
||||
"/api/v1/public-book/departures",
|
||||
json={"registration": "G-BAD", "pob": 0, "out_to": ""},
|
||||
)
|
||||
arrival_response = client.post(
|
||||
"/api/v1/public-book/arrivals",
|
||||
json={"registration": "G-BAD", "pob": 0, "in_from": ""},
|
||||
)
|
||||
|
||||
assert local_response.status_code == 422
|
||||
assert departure_response.status_code == 422
|
||||
assert arrival_response.status_code == 422
|
||||
@@ -41,6 +41,8 @@ CREATE TABLE submitted (
|
||||
departed_dt DATETIME DEFAULT NULL,
|
||||
created_by VARCHAR(16) DEFAULT NULL,
|
||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_dt DATETIME DEFAULT NULL,
|
||||
acknowledged_by VARCHAR(50) DEFAULT NULL,
|
||||
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
|
||||
+3
-15
@@ -25,16 +25,16 @@ services:
|
||||
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}
|
||||
ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING}
|
||||
ENVIRONMENT: production
|
||||
WORKERS: "4"
|
||||
ports:
|
||||
- "${API_PORT_EXTERNAL}:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||
- ./web/assets:/web/assets # Mount assets for QR code generation
|
||||
networks:
|
||||
- app_network
|
||||
extra_hosts:
|
||||
@@ -48,24 +48,12 @@ services:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('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
|
||||
|
||||
+1
-9
@@ -37,7 +37,6 @@ services:
|
||||
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}
|
||||
@@ -48,6 +47,7 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||
- ./web/assets:/web/assets # Mount assets for QR code generation
|
||||
networks:
|
||||
- private_network
|
||||
- public_network
|
||||
@@ -71,14 +71,6 @@ services:
|
||||
networks:
|
||||
- public_network
|
||||
|
||||
# Redis for caching (optional for now)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ppr_nextgen_redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- private_network
|
||||
|
||||
# phpMyAdmin for database management
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
|
||||
@@ -39,11 +39,13 @@ http {
|
||||
|
||||
# Serve HTML files without .html extension (e.g., /admin -> admin.html)
|
||||
location ~ ^/([a-zA-Z0-9_-]+)$ {
|
||||
ssi on;
|
||||
try_files /$1.html =404;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
ssi on;
|
||||
try_files $uri $uri/ =404;
|
||||
# Apply X-Frame-Options to other files
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
@@ -160,6 +160,15 @@ body {
|
||||
background-color: #e67e22;
|
||||
}
|
||||
|
||||
.btn-ack {
|
||||
background-color: #8e44ad;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-ack:hover {
|
||||
background-color: #71368a;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
@@ -330,6 +339,21 @@ tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
tbody tr.ppr-strip-unacknowledged {
|
||||
background-color: #fff0c2;
|
||||
box-shadow: inset 4px 0 0 #f39c12;
|
||||
}
|
||||
|
||||
tbody tr.ppr-strip-unacknowledged:hover {
|
||||
background-color: #ffe6a1;
|
||||
}
|
||||
|
||||
.ack-complete {
|
||||
color: #1e7e34;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
|
||||
+385
-3657
File diff suppressed because it is too large
Load Diff
+1853
File diff suppressed because it is too large
Load Diff
+1287
File diff suppressed because it is too large
Load Diff
+1051
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Drone Flight Request</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #f5f5f5;
|
||||
color: #263645;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin: 2rem auto;
|
||||
max-width: 900px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #3498db;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.header h1 { color: #2c3e50; margin-bottom: 0.4rem; }
|
||||
.status {
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
.status-NEW { background: #3498db; }
|
||||
.status-APPROVED { background: #27ae60; }
|
||||
.status-DENIED { background: #c0392b; }
|
||||
.status-PENDING { background: #f39c12; }
|
||||
.status-CANCELED { background: #7f8c8d; }
|
||||
.status-INFLIGHT { background: #8e44ad; }
|
||||
.status-COMPLETED { background: #2c3e50; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.full { grid-column: 1 / -1; }
|
||||
label {
|
||||
color: #555;
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
input, textarea {
|
||||
border: 1px solid #d6dce2;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
padding: 0.65rem;
|
||||
width: 100%;
|
||||
}
|
||||
textarea { min-height: 110px; resize: vertical; }
|
||||
.actions {
|
||||
border-top: 1px solid #e6e9ec;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.8rem 1.4rem;
|
||||
}
|
||||
.btn-primary { background: #3498db; color: white; }
|
||||
.btn-danger { background: #e74c3c; color: white; }
|
||||
.btn-secondary { background: #95a5a6; color: white; }
|
||||
.message {
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.message.ok { background: #d4edda; color: #155724; display: block; }
|
||||
.message.error { background: #f8d7da; color: #721c24; display: block; }
|
||||
.read-only {
|
||||
background: #eef1f4;
|
||||
border-radius: 5px;
|
||||
color: #607080;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.container { margin: 0; min-height: 100vh; border-radius: 0; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.full { grid-column: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<div class="header">
|
||||
<h1>Drone Flight Request</h1>
|
||||
<p id="summary">Loading request...</p>
|
||||
<span id="status" class="status status-NEW" style="display: none;"></span>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
<div id="locked" class="read-only" style="display: none;">This request can be viewed, but can no longer be edited or cancelled.</div>
|
||||
|
||||
<form id="request-form" style="display: none;">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="operator_name">Operator name</label>
|
||||
<input id="operator_name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="operator_id">Operator ID</label>
|
||||
<input id="operator_id">
|
||||
</div>
|
||||
<div>
|
||||
<label for="flyer_name">Flyer name</label>
|
||||
<input id="flyer_name">
|
||||
</div>
|
||||
<div>
|
||||
<label for="flyer_id">Flyer ID</label>
|
||||
<input id="flyer_id">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone">Phone</label>
|
||||
<input id="phone">
|
||||
</div>
|
||||
<div>
|
||||
<label for="estimated_takeoff_at">Estimated takeoff</label>
|
||||
<input id="estimated_takeoff_at" type="datetime-local" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="estimated_completion_at">Estimated completion</label>
|
||||
<input id="estimated_completion_at" type="datetime-local" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="maximum_elevation_ft_amsl">Maximum elevation ft AMSL</label>
|
||||
<input id="maximum_elevation_ft_amsl" type="number" min="0" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_inside_frz">Inside FRZ</label>
|
||||
<input id="location_inside_frz" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_latitude">Latitude</label>
|
||||
<input id="location_latitude" type="number" step="0.000001" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_longitude">Longitude</label>
|
||||
<input id="location_longitude" type="number" step="0.000001" required>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="location_description">Location description</label>
|
||||
<input id="location_description">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes"></textarea>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="operator_comments">Airport comments</label>
|
||||
<textarea id="operator_comments" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="save-btn" type="submit">Save Changes</button>
|
||||
<button class="btn btn-danger" id="cancel-btn" type="button" onclick="cancelRequest()">Cancel Request</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="loadRequest()">Reload</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
let currentRequest = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('request-form').addEventListener('submit', saveRequest);
|
||||
if (!token) {
|
||||
showMessage('Missing secure request token.', true);
|
||||
return;
|
||||
}
|
||||
loadRequest();
|
||||
});
|
||||
|
||||
async function loadRequest() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to load request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('', false, true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(request) {
|
||||
document.getElementById('summary').textContent = `${request.reference_number} - ${request.operator_name}`;
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = request.status;
|
||||
status.className = `status status-${request.status}`;
|
||||
status.style.display = 'inline-flex';
|
||||
|
||||
setValue('operator_name', request.operator_name);
|
||||
setValue('operator_id', request.operator_id);
|
||||
setValue('flyer_name', request.flyer_name);
|
||||
setValue('flyer_id', request.flyer_id);
|
||||
setValue('email', request.email);
|
||||
setValue('phone', request.phone);
|
||||
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
|
||||
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
|
||||
setValue('maximum_elevation_ft_amsl', request.maximum_elevation_ft_amsl);
|
||||
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
|
||||
setValue('location_latitude', request.location_latitude);
|
||||
setValue('location_longitude', request.location_longitude);
|
||||
setValue('location_description', request.location_description);
|
||||
setValue('notes', request.notes);
|
||||
setValue('operator_comments', request.operator_comments);
|
||||
|
||||
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status);
|
||||
document.getElementById('locked').style.display = locked ? 'block' : 'none';
|
||||
document.getElementById('save-btn').disabled = locked;
|
||||
document.getElementById('cancel-btn').disabled = locked;
|
||||
document.querySelectorAll('#request-form input, #request-form textarea').forEach(input => {
|
||||
if (input.id !== 'operator_comments' && input.id !== 'location_inside_frz') {
|
||||
input.readOnly = locked;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('request-form').style.display = 'block';
|
||||
}
|
||||
|
||||
async function saveRequest(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
operator_name: value('operator_name'),
|
||||
operator_id: value('operator_id') || null,
|
||||
flyer_name: value('flyer_name') || null,
|
||||
flyer_id: value('flyer_id') || null,
|
||||
email: value('email'),
|
||||
phone: value('phone') || null,
|
||||
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
|
||||
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
|
||||
maximum_elevation_ft_amsl: Number(value('maximum_elevation_ft_amsl')),
|
||||
location_latitude: Number(value('location_latitude')),
|
||||
location_longitude: Number(value('location_longitude')),
|
||||
location_description: value('location_description') || null,
|
||||
notes: value('notes') || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to save request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('Request updated.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequest() {
|
||||
if (!confirm('Cancel this drone flight request?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/cancel/${encodeURIComponent(token)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to cancel request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('Request cancelled.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
document.getElementById(id).value = value == null ? '' : value;
|
||||
}
|
||||
|
||||
function value(id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
}
|
||||
|
||||
function toLocalInputValue(value) {
|
||||
if (!value) return '';
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
const date = new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`);
|
||||
return date.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function fromLocalInputValue(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function showMessage(message, isError = false, clear = false) {
|
||||
const element = document.getElementById('message');
|
||||
if (clear || !message) {
|
||||
element.textContent = '';
|
||||
element.className = 'message';
|
||||
return;
|
||||
}
|
||||
element.textContent = message;
|
||||
element.className = `message ${isError ? 'error' : 'ok'}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,672 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Drone Flight Requests</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.95fr) minmax(520px, 1.35fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.request-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-list-body {
|
||||
max-height: calc(100vh - 210px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.request-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #e6e9ec;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.request-row:hover,
|
||||
.request-row.active {
|
||||
background: #eef6fb;
|
||||
}
|
||||
|
||||
.request-ref {
|
||||
font-weight: 700;
|
||||
color: #263645;
|
||||
}
|
||||
|
||||
.request-meta,
|
||||
.detail-meta {
|
||||
color: #5d6d7e;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
align-self: start;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-width: 86px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.status-NEW { background: #3498db; }
|
||||
.status-APPROVED { background: #27ae60; }
|
||||
.status-DENIED { background: #c0392b; }
|
||||
.status-PENDING { background: #f39c12; }
|
||||
.status-CANCELED { background: #7f8c8d; }
|
||||
.status-INFLIGHT { background: #8e44ad; }
|
||||
.status-COMPLETED { background: #2c3e50; }
|
||||
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(360px, 52vh) auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-panel,
|
||||
.action-panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-body,
|
||||
.action-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #607080;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: #263645;
|
||||
font-weight: 600;
|
||||
margin-top: 0.15rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
#request-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid #d6dce2;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
padding: 0.58rem 0.65rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.journal {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid #e6e9ec;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.journal-entry {
|
||||
border-bottom: 1px solid #eef1f4;
|
||||
padding: 0.55rem 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #607080;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
transform: translateY(-120%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace,
|
||||
.detail-grid,
|
||||
.action-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.request-list-body {
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<div class="title">
|
||||
<h1 id="tower-title">Swansea Tower</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<button class="btn btn-secondary" onclick="window.location.href = '/admin'">Tower</button>
|
||||
<button class="btn btn-secondary" onclick="window.location.href = '/journal'">Journal</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Drone Flight Requests</h2>
|
||||
<div class="detail-meta">Requests from the public drone flight form</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="status-filter" aria-label="Status filter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="NEW">New</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="DENIED">Denied</option>
|
||||
<option value="CANCELED">Canceled</option>
|
||||
<option value="INFLIGHT">Inflight</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="request-list">
|
||||
<div class="table-header">Request Queue - <span id="request-count">0</span></div>
|
||||
<div id="request-list-body" class="request-list-body">
|
||||
<div class="empty-state">Loading requests...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-shell">
|
||||
<div class="detail-panel">
|
||||
<div class="table-header">Selected Request</div>
|
||||
<div id="detail-body" class="detail-body">
|
||||
<div class="empty-state">Select a request to view its details.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-panel">
|
||||
<div id="request-map"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-panel">
|
||||
<div class="table-header">Lifecycle</div>
|
||||
<div class="action-body">
|
||||
<div class="action-row">
|
||||
<select id="status-select" aria-label="New status">
|
||||
<option value="NEW">NEW</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="DENIED">DENIED</option>
|
||||
<option value="CANCELED">CANCELED</option>
|
||||
<option value="INFLIGHT">INFLIGHT</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</select>
|
||||
<textarea id="operator-comment" placeholder="Comment or request for more information"></textarea>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<button class="btn btn-success" onclick="saveStatus()">Set Status</button>
|
||||
<button class="btn btn-info" onclick="sendComment()">Send Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="journal" class="journal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notification" class="notification btn"></div>
|
||||
|
||||
<div id="loginModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 420px;">
|
||||
<div class="modal-header">
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="login-username">Username</label>
|
||||
<input type="text" id="login-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="login-password">Password</label>
|
||||
<input type="password" id="login-password" required>
|
||||
</div>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
<div class="form-actions">
|
||||
<button id="login-btn" class="btn btn-primary" type="submit">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
let accessToken = null;
|
||||
let currentUser = null;
|
||||
let selectedRequest = null;
|
||||
let requests = [];
|
||||
let map = null;
|
||||
let mapLayers = [];
|
||||
let frzGeometry = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('status-filter').addEventListener('change', loadRequests);
|
||||
initializeAuth();
|
||||
initializeMap();
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
function initializeMap() {
|
||||
map = L.map('request-map').setView([51.6053, -4.0678], 12);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
loadFrzGeometry();
|
||||
}
|
||||
|
||||
async function loadFrzGeometry() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/drone-requests/frz');
|
||||
if (!response.ok) throw new Error('Failed to load FRZ geometry');
|
||||
frzGeometry = await response.json();
|
||||
renderMap();
|
||||
} catch (err) {
|
||||
console.warn('Unable to load FRZ geometry', err);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAuth() {
|
||||
const token = localStorage.getItem('ppr_access_token');
|
||||
const username = localStorage.getItem('ppr_username');
|
||||
const expiry = localStorage.getItem('ppr_token_expiry');
|
||||
if (token && username && expiry && Date.now() < parseInt(expiry)) {
|
||||
accessToken = token;
|
||||
currentUser = username;
|
||||
document.getElementById('current-user').textContent = username;
|
||||
loadRequests();
|
||||
return;
|
||||
}
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loginModal').style.display = 'block';
|
||||
document.getElementById('login-username').focus();
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
document.getElementById('loginModal').style.display = 'none';
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
document.getElementById('login-form').reset();
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('login-username').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
const error = document.getElementById('login-error');
|
||||
const button = document.getElementById('login-btn');
|
||||
button.disabled = true;
|
||||
error.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Login failed');
|
||||
|
||||
const expiry = Date.now() + ((data.expires_in || 1800) * 1000);
|
||||
localStorage.setItem('ppr_access_token', data.access_token);
|
||||
localStorage.setItem('ppr_username', username);
|
||||
localStorage.setItem('ppr_token_expiry', expiry.toString());
|
||||
accessToken = data.access_token;
|
||||
currentUser = username;
|
||||
document.getElementById('current-user').textContent = username;
|
||||
hideLogin();
|
||||
loadRequests();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.style.display = 'block';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('ppr_access_token');
|
||||
localStorage.removeItem('ppr_username');
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
accessToken = null;
|
||||
currentUser = null;
|
||||
selectedRequest = null;
|
||||
showLogin();
|
||||
}
|
||||
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
if (!accessToken) {
|
||||
showLogin();
|
||||
throw new Error('No access token');
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
if (!accessToken) return;
|
||||
const status = document.getElementById('status-filter').value;
|
||||
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
|
||||
const body = document.getElementById('request-list-body');
|
||||
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load drone requests');
|
||||
requests = await response.json();
|
||||
renderRequestList();
|
||||
if (selectedRequest) {
|
||||
const fresh = requests.find(r => r.id === selectedRequest.id);
|
||||
if (fresh) selectRequest(fresh.id);
|
||||
}
|
||||
} catch (err) {
|
||||
body.innerHTML = `<div class="empty-state">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequestList() {
|
||||
document.getElementById('request-count').textContent = requests.length;
|
||||
const body = document.getElementById('request-list-body');
|
||||
if (!requests.length) {
|
||||
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = requests.map(req => `
|
||||
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
||||
<div>
|
||||
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
|
||||
<div class="request-meta">${escapeHtml(req.operator_name)} · ${formatDateTime(req.estimated_takeoff_at)}</div>
|
||||
<div class="request-meta">${escapeHtml(req.location_description || `${req.location_latitude}, ${req.location_longitude}`)}</div>
|
||||
</div>
|
||||
<span class="status-pill status-${req.status}">${req.status}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function selectRequest(id) {
|
||||
selectedRequest = requests.find(req => req.id === id);
|
||||
if (!selectedRequest) return;
|
||||
renderRequestList();
|
||||
renderDetails();
|
||||
renderMap();
|
||||
await loadJournal();
|
||||
}
|
||||
|
||||
function renderDetails() {
|
||||
document.getElementById('status-select').value = selectedRequest.status;
|
||||
document.getElementById('operator-comment').value = selectedRequest.operator_comments || '';
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<h2>${escapeHtml(selectedRequest.reference_number)}</h2>
|
||||
<div class="detail-meta">${escapeHtml(selectedRequest.operator_name)} · ${escapeHtml(selectedRequest.email)} · ${escapeHtml(selectedRequest.phone || '-')}</div>
|
||||
</div>
|
||||
<span class="status-pill status-${selectedRequest.status}">${selectedRequest.status}</span>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
${field('Operator ID', selectedRequest.operator_id)}
|
||||
${field('Flyer', selectedRequest.flyer_name)}
|
||||
${field('Flyer ID', selectedRequest.flyer_id)}
|
||||
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
|
||||
${field('Completion', formatDateTime(selectedRequest.estimated_completion_at))}
|
||||
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_amsl} ft AMSL`)}
|
||||
${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
|
||||
${field('Latitude', selectedRequest.location_latitude)}
|
||||
${field('Longitude', selectedRequest.location_longitude)}
|
||||
${field('Location', selectedRequest.location_description || '-')}
|
||||
${field('Applicant Notes', selectedRequest.notes || '-')}
|
||||
${field('Operator Comments', selectedRequest.operator_comments || '-')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return `<div><div class="field-label">${escapeHtml(label)}</div><div class="field-value">${escapeHtml(value == null ? '-' : String(value))}</div></div>`;
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
clearMapLayers();
|
||||
if (!map) return;
|
||||
|
||||
if (frzGeometry) {
|
||||
const frzLayer = L.geoJSON(frzGeometry, {
|
||||
style: {
|
||||
color: '#e74c3c',
|
||||
weight: 2,
|
||||
fillColor: '#e74c3c',
|
||||
fillOpacity: 0.10
|
||||
}
|
||||
}).addTo(map);
|
||||
addLayer(frzLayer);
|
||||
}
|
||||
|
||||
if (selectedRequest) {
|
||||
const point = [selectedRequest.location_latitude, selectedRequest.location_longitude];
|
||||
addLayer(L.marker(point).addTo(map).bindPopup(`
|
||||
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
|
||||
${escapeHtml(selectedRequest.operator_name)}<br>
|
||||
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL
|
||||
`));
|
||||
}
|
||||
|
||||
if (!mapLayers.length) return;
|
||||
const group = L.featureGroup(mapLayers);
|
||||
map.fitBounds(group.getBounds().pad(0.18));
|
||||
setTimeout(() => map.invalidateSize(), 50);
|
||||
}
|
||||
|
||||
function addLayer(layer) {
|
||||
mapLayers.push(layer);
|
||||
}
|
||||
|
||||
function clearMapLayers() {
|
||||
mapLayers.forEach(layer => map.removeLayer(layer));
|
||||
mapLayers = [];
|
||||
}
|
||||
|
||||
async function saveStatus() {
|
||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||
const status = document.getElementById('status-select').value;
|
||||
const comment = document.getElementById('operator-comment').value.trim();
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, comment: comment || null })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update status');
|
||||
selectedRequest = await response.json();
|
||||
showNotification('Status updated');
|
||||
await loadRequests();
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
} catch (err) {
|
||||
showNotification(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendComment() {
|
||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||
const comment = document.getElementById('operator-comment').value.trim();
|
||||
if (!comment) return showNotification('Enter a comment first', true);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ comment, email_applicant: true })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to send comment');
|
||||
selectedRequest = await response.json();
|
||||
showNotification('Comment sent');
|
||||
await loadRequests();
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
} catch (err) {
|
||||
showNotification(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJournal() {
|
||||
const journal = document.getElementById('journal');
|
||||
if (!selectedRequest) {
|
||||
journal.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/journal`);
|
||||
if (!response.ok) throw new Error('Failed to load journal');
|
||||
const entries = await response.json();
|
||||
journal.innerHTML = entries.length ? entries.map(entry => `
|
||||
<div class="journal-entry">
|
||||
<div>${escapeHtml(entry.entry)}</div>
|
||||
<div class="request-meta">${escapeHtml(entry.user)} · ${formatDateTime(entry.entry_dt)}</div>
|
||||
</div>
|
||||
`).join('') : '<div class="request-meta">No journal entries yet.</div>';
|
||||
} catch (err) {
|
||||
journal.innerHTML = `<div class="request-meta">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/tower-updates`);
|
||||
ws.onmessage = event => {
|
||||
if (event.data.startsWith('Heartbeat:')) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type && data.type.startsWith('drone_request_')) loadRequests();
|
||||
} catch (err) {
|
||||
console.warn('WebSocket parse failed', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
const date = new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`);
|
||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `notification btn ${isError ? 'btn-danger' : 'btn-success'} show`;
|
||||
setTimeout(() => notification.classList.remove('show'), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -692,6 +692,9 @@
|
||||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Auto-save any unsaved aircraft types
|
||||
await autoSaveUnsavedAircraft(this);
|
||||
|
||||
const formData = new FormData(this);
|
||||
const pprData = {};
|
||||
|
||||
|
||||
+45
-1
@@ -178,6 +178,35 @@
|
||||
left: 28px;
|
||||
}
|
||||
|
||||
/* QR code for booking */
|
||||
.qr-code-container {
|
||||
position: absolute;
|
||||
left: 300px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: white;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.qr-code-container .qr-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qr-code-container img {
|
||||
display: block;
|
||||
max-width: 120px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Santa hat styles */
|
||||
.santa-hat {
|
||||
position: absolute;
|
||||
@@ -357,6 +386,10 @@
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
||||
<div class="qr-code-container">
|
||||
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
||||
<div class="qr-label">Book Out</div>
|
||||
</div>
|
||||
<h1>Flight Information</h1>
|
||||
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
||||
</header>
|
||||
@@ -666,7 +699,7 @@
|
||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
||||
sortTime = arrival.landed_dt;
|
||||
} else {
|
||||
// Show ETA if BOOKED_IN
|
||||
// Show ETA if INBOUND
|
||||
const time = convertToLocalTime(arrival.eta);
|
||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
||||
sortTime = arrival.eta;
|
||||
@@ -847,11 +880,22 @@
|
||||
return typeMap[flightType] || flightType;
|
||||
}
|
||||
|
||||
// Generate QR code for booking page
|
||||
function generateBookingQR() {
|
||||
const qrImg = document.getElementById('bookingQR');
|
||||
if (qrImg) {
|
||||
qrImg.src = '/assets/booking-qr.png';
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
window.addEventListener('load', function() {
|
||||
// Initialize Christmas mode
|
||||
initChristmasMode();
|
||||
|
||||
// Load booking QR code
|
||||
generateBookingQR();
|
||||
|
||||
loadArrivals();
|
||||
loadDepartures();
|
||||
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Journal - PPR System</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<script src="config.js"></script>
|
||||
<script src="lookups.js"></script>
|
||||
<style>
|
||||
.journal-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.journal-filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.journal-filters input,
|
||||
.journal-filters select {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-apply:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #757575;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.journal-table {
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.journal-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.journal-table thead {
|
||||
background: #333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.journal-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.journal-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.journal-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.entity-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.entity-badge-ppr { background: #2196F3; }
|
||||
.entity-badge-local_flight { background: #FF9800; }
|
||||
.entity-badge-arrival { background: #4CAF50; }
|
||||
.entity-badge-departure { background: #9C27B0; }
|
||||
.entity-badge-overflight { background: #00BCD4; }
|
||||
.entity-badge-circuit { background: #FFC107; }
|
||||
.entity-badge-user { background: #795548; }
|
||||
|
||||
.entry-text {
|
||||
color: #555;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.entry-datetime {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.entry-user {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.entry-ip {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.pagination button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.pagination button.active {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border-color: #2196F3;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #2196F3;
|
||||
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); }
|
||||
}
|
||||
|
||||
.export-section {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-export:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<div class="title">
|
||||
<h1>📔 Journal Log</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
|
||||
⚙️ Menu
|
||||
</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
<a href="#" onclick="window.location.href = '/admin'">📋 Admin</a>
|
||||
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
|
||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Filters -->
|
||||
<div class="journal-filters">
|
||||
<label>
|
||||
Date From:
|
||||
<input type="date" id="dateFrom">
|
||||
</label>
|
||||
<label>
|
||||
Date To:
|
||||
<input type="date" id="dateTo">
|
||||
</label>
|
||||
<label>
|
||||
Entity Type:
|
||||
<select id="entityType">
|
||||
<option value="">All Types</option>
|
||||
<option value="PPR">PPR</option>
|
||||
<option value="LOCAL_FLIGHT">Local Flight</option>
|
||||
<option value="ARRIVAL">Arrival</option>
|
||||
<option value="DEPARTURE">Departure</option>
|
||||
<option value="OVERFLIGHT">Overflight</option>
|
||||
<option value="CIRCUIT">Circuit</option>
|
||||
<option value="USER">User</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
User:
|
||||
<input type="text" id="filterUser" placeholder="e.g., john_doe">
|
||||
</label>
|
||||
<label>
|
||||
Entity ID:
|
||||
<input type="number" id="entityId" placeholder="Optional">
|
||||
</label>
|
||||
<label>
|
||||
Search Text:
|
||||
<input type="text" id="searchText" placeholder="Search in entries...">
|
||||
</label>
|
||||
<div class="filter-buttons">
|
||||
<button class="btn-apply" onclick="applyFilters()">🔍 Search</button>
|
||||
<button class="btn-reset" onclick="resetFilters()">↻ Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalEntries">0</div>
|
||||
<div class="stat-label">Total Entries</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="uniqueUsers">0</div>
|
||||
<div class="stat-label">Unique Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="dateRange">-</div>
|
||||
<div class="stat-label">Date Range</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="export-section">
|
||||
<button class="btn-export" onclick="exportToCSV()">📥 Export as CSV</button>
|
||||
<button class="btn-export" onclick="exportToJSON()">📥 Export as JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Journal Table -->
|
||||
<div class="journal-table">
|
||||
<div id="journal-loading" class="loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
Loading journal entries...
|
||||
</div>
|
||||
|
||||
<div id="journal-content" style="display: none;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date/Time</th>
|
||||
<th>Entity Type</th>
|
||||
<th>Entity ID</th>
|
||||
<th>User</th>
|
||||
<th>Entry</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="journal-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="journal-no-data" class="no-data" style="display: none;">
|
||||
<h3>No Journal Entries Found</h3>
|
||||
<p>Try adjusting your filters</p>
|
||||
</div>
|
||||
|
||||
<div id="journal-error" class="no-data" style="display: none; color: #d32f2f;">
|
||||
<h3>⚠️ Error Loading Journal</h3>
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="pagination" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.PPR_CONFIG.apiBase;
|
||||
let allEntries = [];
|
||||
let filteredEntries = [];
|
||||
let currentPage = 1;
|
||||
const entriesPerPage = 50;
|
||||
let accessToken = null;
|
||||
let currentUser = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeAuth();
|
||||
});
|
||||
|
||||
async function initializeAuth() {
|
||||
// Try to get cached token
|
||||
const cachedToken = localStorage.getItem('ppr_access_token');
|
||||
const cachedUser = localStorage.getItem('ppr_username');
|
||||
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
|
||||
|
||||
if (cachedToken && cachedUser && tokenExpiry) {
|
||||
const now = new Date().getTime();
|
||||
if (now < parseInt(tokenExpiry)) {
|
||||
// Token is still valid
|
||||
accessToken = cachedToken;
|
||||
currentUser = cachedUser;
|
||||
document.getElementById('current-user').textContent = cachedUser;
|
||||
setDefaultDates();
|
||||
loadJournalEntries();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid cached token, show error or redirect to login
|
||||
showError('Session expired or not authenticated. Please log in through the admin page.');
|
||||
}
|
||||
|
||||
function setDefaultDates() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
|
||||
document.getElementById('dateTo').valueAsDate = today;
|
||||
}
|
||||
|
||||
async function loadJournalEntries() {
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
if (!accessToken) {
|
||||
showError('No authentication token available.');
|
||||
showLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the new search endpoint with default date range
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
limit: 500
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE}/journal/search/all?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
showError('Session expired. Please log in again through the admin page.');
|
||||
showLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load journal entries: ${response.statusText}`);
|
||||
}
|
||||
|
||||
allEntries = await response.json();
|
||||
|
||||
showLoading(false);
|
||||
updateStats();
|
||||
applyFilters();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading journal:', error);
|
||||
showError(error.message);
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
showLoading(true);
|
||||
|
||||
if (!accessToken) {
|
||||
showError('No authentication token available. Please log in again.');
|
||||
showLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
const entityType = document.getElementById('entityType').value;
|
||||
const filterUser = document.getElementById('filterUser').value;
|
||||
const entityId = document.getElementById('entityId').value;
|
||||
const searchText = document.getElementById('searchText').value.toLowerCase();
|
||||
|
||||
// Build API request with filters
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.append('date_from', dateFrom);
|
||||
if (dateTo) params.append('date_to', dateTo);
|
||||
if (entityType) params.append('entity_type', entityType);
|
||||
if (entityId) params.append('entity_id', entityId);
|
||||
if (filterUser) params.append('user', filterUser);
|
||||
params.append('limit', 500);
|
||||
|
||||
fetch(`${API_BASE}/journal/search/all?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Session expired. Please log in again through the admin page.');
|
||||
}
|
||||
if (!response.ok) throw new Error(`Search failed: ${response.statusText}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
filteredEntries = data;
|
||||
|
||||
// Apply client-side text search if any
|
||||
if (searchText) {
|
||||
filteredEntries = filteredEntries.filter(entry =>
|
||||
entry.entry.toLowerCase().includes(searchText)
|
||||
);
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
showLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error searching journal:', error);
|
||||
showError(error.message);
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function displayEntries() {
|
||||
const start = (currentPage - 1) * entriesPerPage;
|
||||
const end = start + entriesPerPage;
|
||||
const pageEntries = filteredEntries.slice(start, end);
|
||||
|
||||
if (pageEntries.length === 0) {
|
||||
document.getElementById('journal-table-body').innerHTML = '';
|
||||
document.getElementById('journal-content').style.display = 'none';
|
||||
document.getElementById('journal-no-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const tableBody = document.getElementById('journal-table-body');
|
||||
tableBody.innerHTML = pageEntries.map(entry => `
|
||||
<tr>
|
||||
<td class="entry-datetime">${formatDateTime(entry.entry_dt)}</td>
|
||||
<td><span class="entity-type-badge entity-badge-${entry.entity_type.toLowerCase()}">${entry.entity_type}</span></td>
|
||||
<td>${entry.entity_id}</td>
|
||||
<td class="entry-user">${entry.user}</td>
|
||||
<td class="entry-text">${escapeHtml(entry.entry)}</td>
|
||||
<td class="entry-ip">${entry.ip || '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('journal-content').style.display = 'block';
|
||||
document.getElementById('journal-no-data').style.display = 'none';
|
||||
document.getElementById('journal-error').style.display = 'none';
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
const totalPages = Math.ceil(filteredEntries.length / entriesPerPage);
|
||||
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const paginationDiv = document.getElementById('pagination');
|
||||
paginationDiv.innerHTML = '';
|
||||
|
||||
if (currentPage > 1) {
|
||||
const prevBtn = document.createElement('button');
|
||||
prevBtn.textContent = '← Previous';
|
||||
prevBtn.onclick = () => {
|
||||
currentPage--;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
};
|
||||
paginationDiv.appendChild(prevBtn);
|
||||
}
|
||||
|
||||
// Show page numbers
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
if (startPage > 1) {
|
||||
const firstBtn = document.createElement('button');
|
||||
firstBtn.textContent = '1';
|
||||
firstBtn.onclick = () => {
|
||||
currentPage = 1;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
};
|
||||
paginationDiv.appendChild(firstBtn);
|
||||
|
||||
if (startPage > 2) {
|
||||
const dots = document.createElement('span');
|
||||
dots.textContent = '...';
|
||||
dots.style.padding = '0.5rem 0.5rem';
|
||||
paginationDiv.appendChild(dots);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = i;
|
||||
if (i === currentPage) btn.className = 'active';
|
||||
btn.onclick = () => {
|
||||
currentPage = i;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
};
|
||||
paginationDiv.appendChild(btn);
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
const dots = document.createElement('span');
|
||||
dots.textContent = '...';
|
||||
dots.style.padding = '0.5rem 0.5rem';
|
||||
paginationDiv.appendChild(dots);
|
||||
}
|
||||
|
||||
const lastBtn = document.createElement('button');
|
||||
lastBtn.textContent = totalPages;
|
||||
lastBtn.onclick = () => {
|
||||
currentPage = totalPages;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
};
|
||||
paginationDiv.appendChild(lastBtn);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.textContent = 'Next →';
|
||||
nextBtn.onclick = () => {
|
||||
currentPage++;
|
||||
displayEntries();
|
||||
updatePagination();
|
||||
};
|
||||
paginationDiv.appendChild(nextBtn);
|
||||
}
|
||||
|
||||
paginationDiv.style.display = 'flex';
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('totalEntries').textContent = allEntries.length;
|
||||
|
||||
const uniqueUsers = new Set(allEntries.map(e => e.user)).size;
|
||||
document.getElementById('uniqueUsers').textContent = uniqueUsers;
|
||||
|
||||
if (allEntries.length > 0) {
|
||||
const dates = allEntries
|
||||
.map(e => new Date(e.entry_dt))
|
||||
.sort((a, b) => a - b);
|
||||
const earliest = formatDate(dates[0]);
|
||||
const latest = formatDate(dates[dates.length - 1]);
|
||||
document.getElementById('dateRange').textContent = `${earliest} to ${latest}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setDefaultDates();
|
||||
document.getElementById('entityType').value = '';
|
||||
document.getElementById('filterUser').value = '';
|
||||
document.getElementById('entityId').value = '';
|
||||
document.getElementById('searchText').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
document.getElementById('journal-loading').style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
document.getElementById('error-message').textContent = message;
|
||||
document.getElementById('journal-error').style.display = 'block';
|
||||
document.getElementById('journal-content').style.display = 'none';
|
||||
document.getElementById('journal-no-data').style.display = 'none';
|
||||
}
|
||||
|
||||
function exportToCSV() {
|
||||
let csv = 'Date/Time,Entity Type,Entity ID,User,Entry,IP Address\n';
|
||||
|
||||
filteredEntries.forEach(entry => {
|
||||
const row = [
|
||||
formatDateTime(entry.entry_dt),
|
||||
entry.entity_type,
|
||||
entry.entity_id,
|
||||
entry.user,
|
||||
`"${entry.entry.replace(/"/g, '""')}"`,
|
||||
entry.ip || ''
|
||||
];
|
||||
csv += row.join(',') + '\n';
|
||||
});
|
||||
|
||||
downloadFile(csv, 'journal_export.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportToJSON() {
|
||||
const json = JSON.stringify(filteredEntries, null, 2);
|
||||
downloadFile(json, 'journal_export.json', 'application/json');
|
||||
}
|
||||
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('ppr_access_token');
|
||||
localStorage.removeItem('ppr_username');
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+155
-5
@@ -163,15 +163,26 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
const resultsDiv = document.getElementById(resultsId);
|
||||
|
||||
if (config.isAircraft) {
|
||||
// Aircraft lookup: auto-populate on single match, format input on no match
|
||||
// Aircraft lookup: auto-populate on single match, mark form for auto-save on no match
|
||||
if (!results || results.length === 0) {
|
||||
// Format the aircraft registration and auto-populate
|
||||
// Format the aircraft registration
|
||||
const formatted = formatAircraftRegistration(searchTerm);
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
if (field && fieldId !== 'aircraft-registration') {
|
||||
field.value = formatted;
|
||||
// Mark the form for auto-saving this aircraft
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
form.setAttribute('data-unsaved-aircraft', fieldId);
|
||||
}
|
||||
}
|
||||
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
|
||||
|
||||
// Show message that type will be saved
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-no-match">
|
||||
No match found - aircraft type will be saved automatically when you submit
|
||||
</div>
|
||||
`;
|
||||
} else if (results.length === 1) {
|
||||
// Single match - auto-populate
|
||||
const aircraft = results[0];
|
||||
@@ -183,7 +194,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
|
||||
// Auto-populate the form fields
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) field.value = aircraft.registration;
|
||||
if (field && fieldId !== 'aircraft-registration') {
|
||||
field.value = aircraft.registration;
|
||||
// Clear the unsaved aircraft flag since we found a match
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
form.removeAttribute('data-unsaved-aircraft');
|
||||
}
|
||||
}
|
||||
|
||||
// Also populate type field
|
||||
let typeFieldId;
|
||||
@@ -195,6 +213,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
typeFieldId = 'book_in_type';
|
||||
} else if (fieldId === 'overflight_registration') {
|
||||
typeFieldId = 'overflight_type';
|
||||
} else if (fieldId === 'aircraft-registration') {
|
||||
typeFieldId = 'aircraft-type';
|
||||
}
|
||||
|
||||
if (typeFieldId) {
|
||||
@@ -208,6 +228,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
Multiple matches found (${results.length}) - please be more specific
|
||||
</div>
|
||||
`;
|
||||
// Clear the unsaved aircraft flag since multiple matches found
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
form.removeAttribute('data-unsaved-aircraft');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Airport lookup: show list of options with keyboard navigation
|
||||
@@ -304,6 +332,8 @@ const lookupManager = {
|
||||
}
|
||||
};
|
||||
|
||||
window.lookupManager = lookupManager;
|
||||
|
||||
// Initialize all lookups when page loads
|
||||
function initializeLookups() {
|
||||
// Create reusable lookup instances
|
||||
@@ -387,6 +417,14 @@ function initializeLookups() {
|
||||
);
|
||||
lookupManager.register('overflight-destination', overflightDestinationLookup);
|
||||
|
||||
const bulkAircraftLookup = createLookup(
|
||||
'aircraft-registration',
|
||||
'bulk-aircraft-lookup-results',
|
||||
null,
|
||||
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||
);
|
||||
lookupManager.register('bulk-aircraft', bulkAircraftLookup);
|
||||
|
||||
// Attach keyboard handlers to airport input fields
|
||||
setTimeout(() => {
|
||||
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
|
||||
@@ -433,6 +471,11 @@ function handleLocalAircraftLookup(value) {
|
||||
if (lookup) lookup.handle(value);
|
||||
}
|
||||
|
||||
function handleBulkAircraftLookup(value) {
|
||||
const lookup = lookupManager.lookups['bulk-aircraft'];
|
||||
if (lookup) lookup.handle(value);
|
||||
}
|
||||
|
||||
function clearArrivalAirportLookup() {
|
||||
const lookup = lookupManager.lookups['arrival-airport'];
|
||||
if (lookup) lookup.clear();
|
||||
@@ -501,3 +544,110 @@ function selectBookInAircraft(registration) {
|
||||
function selectBookInArrivalAirport(icaoCode) {
|
||||
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
|
||||
}
|
||||
|
||||
// Save user aircraft type for future lookups
|
||||
async function saveUserAircraft(registrationFieldId, resultsDivId) {
|
||||
const regField = document.getElementById(registrationFieldId);
|
||||
if (!regField || !regField.value.trim()) {
|
||||
showNotification('Please enter a registration first', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the type field ID based on registration field
|
||||
let typeFieldId;
|
||||
if (registrationFieldId === 'ac_reg') {
|
||||
typeFieldId = 'ac_type';
|
||||
} else if (registrationFieldId === 'local_registration') {
|
||||
typeFieldId = 'local_type';
|
||||
} else if (registrationFieldId === 'book_in_registration') {
|
||||
typeFieldId = 'book_in_type';
|
||||
} else if (registrationFieldId === 'overflight_registration') {
|
||||
typeFieldId = 'overflight_type';
|
||||
}
|
||||
|
||||
const typeField = document.getElementById(typeFieldId);
|
||||
if (!typeField || !typeField.value.trim()) {
|
||||
showNotification('Please enter an aircraft type first', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
registration: regField.value.trim(),
|
||||
type_code: typeField.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showNotification('Aircraft type saved for future use');
|
||||
|
||||
// Clear the results div
|
||||
const resultsDiv = document.getElementById(resultsDivId);
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(error.detail || 'Failed to save aircraft', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving aircraft:', error);
|
||||
showNotification('Error saving aircraft', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save unsaved aircraft before form submission
|
||||
async function autoSaveUnsavedAircraft(form) {
|
||||
const unsavedFieldId = form.getAttribute('data-unsaved-aircraft');
|
||||
if (!unsavedFieldId) return; // No unsaved aircraft to save
|
||||
|
||||
const regField = document.getElementById(unsavedFieldId);
|
||||
if (!regField || !regField.value.trim()) return;
|
||||
|
||||
// Determine the type field ID
|
||||
let typeFieldId;
|
||||
if (unsavedFieldId === 'ac_reg') {
|
||||
typeFieldId = 'ac_type';
|
||||
} else if (unsavedFieldId === 'local_registration') {
|
||||
typeFieldId = 'local_type';
|
||||
} else if (unsavedFieldId === 'book_in_registration') {
|
||||
typeFieldId = 'book_in_type';
|
||||
} else if (unsavedFieldId === 'overflight_registration') {
|
||||
typeFieldId = 'overflight_type';
|
||||
}
|
||||
|
||||
const typeField = document.getElementById(typeFieldId);
|
||||
if (!typeField || !typeField.value.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
registration: regField.value.trim(),
|
||||
type_code: typeField.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Successfully saved, remove the flag
|
||||
form.removeAttribute('data-unsaved-aircraft');
|
||||
console.log('Auto-saved aircraft type for', regField.value.trim());
|
||||
} else if (response.status === 400) {
|
||||
// Already exists, just remove the flag
|
||||
form.removeAttribute('data-unsaved-aircraft');
|
||||
} else {
|
||||
console.error('Failed to auto-save aircraft');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error auto-saving aircraft:', error);
|
||||
}
|
||||
}
|
||||
|
||||
+1278
File diff suppressed because it is too large
Load Diff
@@ -358,6 +358,7 @@
|
||||
min-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
@@ -365,6 +366,9 @@
|
||||
<button class="btn btn-secondary" onclick="window.location.href='admin'">
|
||||
← Back to Admin
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="window.location.href='journal'">
|
||||
📔 Journal Log
|
||||
</button>
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1 id="tower-title">📊 PPR Reports</h1>
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
<!-- Login Modal -->
|
||||
<div id="loginModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>PPR Admin Login</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="login-username">Username:</label>
|
||||
<input type="text" id="login-username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="login-password">Password:</label>
|
||||
<input type="password" id="login-password" name="password" required>
|
||||
</div>
|
||||
<div id="login-error" style="color: #dc3545; margin: 1rem 0; display: none;"></div>
|
||||
<div class="form-actions" style="border-top: none; padding-top: 0;">
|
||||
<button type="submit" class="btn btn-success" id="login-btn">
|
||||
🔐 Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PPR Detail/Edit Modal -->
|
||||
<div id="pprModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">PPR Details</h2>
|
||||
<button class="close" onclick="closePPRModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions">
|
||||
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
|
||||
🛬 Land
|
||||
</button>
|
||||
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
|
||||
🛫 Depart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="ppr-form">
|
||||
<input type="hidden" id="ppr-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ac_reg">Aircraft Registration *</label>
|
||||
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
|
||||
<div id="aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_type">Aircraft Type *</label>
|
||||
<input type="text" id="ac_type" name="ac_type" required tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_call">Callsign</label>
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="captain">Captain *</label>
|
||||
<input type="text" id="captain" name="captain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="in_from">Arriving From *</label>
|
||||
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
|
||||
<div id="arrival-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eta">ETA (Local Time) *</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_in">POB Inbound *</label>
|
||||
<input type="number" id="pob_in" name="pob_in" required min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fuel">Fuel Required</label>
|
||||
<select id="fuel" name="fuel" tabindex="-1">
|
||||
<option value="">None</option>
|
||||
<option value="100LL">100LL</option>
|
||||
<option value="JET A1">JET A1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="out_to">Departing To</label>
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
|
||||
<div id="departure-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="etd">ETD (Local Time)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
||||
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_out">POB Outbound</label>
|
||||
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closePPRModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-cancel" onclick="updateStatus('CANCELED')">
|
||||
❌ Cancel PPR
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
💾 Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="journal-section" id="journal-section">
|
||||
<h3>Activity Journal</h3>
|
||||
<div id="journal-entries" class="journal-entries">
|
||||
Loading journal...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Flight (Book Out) Modal -->
|
||||
<div id="localFlightModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="local-flight-modal-title">Book Out</h2>
|
||||
<button class="close" onclick="closeModal('localFlightModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="local-flight-form">
|
||||
<input type="hidden" id="local-flight-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="local_registration">Aircraft Registration *</label>
|
||||
<input type="text" id="local_registration" name="registration" required oninput="handleLocalAircraftLookup(this.value)" tabindex="1">
|
||||
<div id="local-aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_type">Aircraft Type</label>
|
||||
<input type="text" id="local_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_callsign">Callsign (optional)</label>
|
||||
<input type="text" id="local_callsign" name="callsign" placeholder="If different from registration" tabindex="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_pob">Persons on Board *</label>
|
||||
<input type="number" id="local_pob" name="pob" required min="1" tabindex="2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_flight_type">Flight Type *</label>
|
||||
<select id="local_flight_type" name="flight_type" required tabindex="5" onchange="handleFlightTypeChange(this.value)">
|
||||
<option value="LOCAL">Local Flight</option>
|
||||
<option value="CIRCUITS">Circuits</option>
|
||||
<option value="DEPARTURE">Departure to Other Airport</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_duration">Duration (minutes)</label>
|
||||
<input type="number" id="local_duration" name="duration" min="5" max="480" value="45" placeholder="Duration in minutes" tabindex="7">
|
||||
</div>
|
||||
<div class="form-group" id="departure-destination-group" style="display: none;">
|
||||
<label for="local_out_to" id="departure-destination-label">Destination Airport</label>
|
||||
<input type="text" id="local_out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleLocalOutToAirportLookup(this.value)" tabindex="3">
|
||||
<div id="local-out-to-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_etd_time">ETD (Estimated Time of Departure) *</label>
|
||||
<select id="local_etd_time" name="etd_time" required>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="local_notes">Notes</label>
|
||||
<textarea id="local_notes" name="notes" rows="3" placeholder="e.g., destination, any special requirements"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeModal('localFlightModal')">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
🛫 Book Out
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Flight Edit Modal -->
|
||||
<div id="localFlightEditModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="local-flight-edit-title">Local Flight Details</h2>
|
||||
<button class="close" onclick="closeLocalFlightEditModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions">
|
||||
<button id="local-btn-departed" class="btn btn-primary btn-sm" onclick="updateLocalFlightStatus('DEPARTED')" style="display: none;">
|
||||
🛫 Mark Departed
|
||||
</button>
|
||||
<button id="local-btn-landed" class="btn btn-warning btn-sm" onclick="updateLocalFlightStatus('LANDED')" style="display: none;">
|
||||
🛬 Land
|
||||
</button>
|
||||
<button id="local-btn-cancel" class="btn btn-danger btn-sm" onclick="updateLocalFlightStatus('CANCELLED')" style="display: none;">
|
||||
❌ Cancel Flight
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="local-flight-edit-form">
|
||||
<input type="hidden" id="local-edit-flight-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="local_edit_registration">Aircraft Registration</label>
|
||||
<input type="text" id="local_edit_registration" name="registration">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_type">Aircraft Type</label>
|
||||
<input type="text" id="local_edit_type" name="type">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_callsign">Callsign</label>
|
||||
<input type="text" id="local_edit_callsign" name="callsign">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_pob">POB</label>
|
||||
<input type="number" id="local_edit_pob" name="pob" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_flight_type">Flight Type</label>
|
||||
<select id="local_edit_flight_type" name="flight_type">
|
||||
<option value="LOCAL">Local Flight</option>
|
||||
<option value="CIRCUITS">Circuits</option>
|
||||
<option value="DEPARTURE">Departure</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_duration">Duration (minutes)</label>
|
||||
<input type="number" id="local_edit_duration" name="duration" min="5" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="local_edit_takeoff_dt">Takeoff Time</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="local_edit_departure_date" name="departure_date" style="flex: 1;">
|
||||
<input type="time" id="local_edit_departure_time" name="departure_time" style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="local-edit-landing-group">
|
||||
<label for="local_edit_landed_dt">Landing Time</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="local_edit_landed_date" name="landed_date" style="flex: 1;">
|
||||
<input type="time" id="local_edit_landed_time" name="landed_time" style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="local_edit_notes">Notes</label>
|
||||
<textarea id="local_edit_notes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeLocalFlightEditModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
💾 Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Touch & Go Records Section (for all local flight types) -->
|
||||
<div id="circuits-section" style="display: none; margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||||
<h3>✈️ Touch & Go Records</h3>
|
||||
<div id="circuits-list" style="margin-top: 1rem;">
|
||||
<p style="color: #666; font-style: italic;">Loading circuits...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Journal Section -->
|
||||
<div id="local-flight-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||||
<h3>📋 Activity Journal</h3>
|
||||
<div id="local-flight-journal-entries" class="journal-entries">
|
||||
Loading journal...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book In Modal -->
|
||||
<div id="bookInModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Book In</h2>
|
||||
<button class="close" onclick="closeModal('bookInModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="book-in-form">
|
||||
<input type="hidden" id="book-in-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="book_in_registration">Aircraft Registration *</label>
|
||||
<input type="text" id="book_in_registration" name="registration" required oninput="handleBookInAircraftLookup(this.value)" tabindex="1">
|
||||
<div id="book-in-aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="book_in_type">Aircraft Type</label>
|
||||
<input type="text" id="book_in_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="book_in_callsign">Callsign (optional)</label>
|
||||
<input type="text" id="book_in_callsign" name="callsign" placeholder="If different from registration" tabindex="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="book_in_pob">Persons on Board *</label>
|
||||
<input type="number" id="book_in_pob" name="pob" required min="1" tabindex="2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="book_in_from">Coming From (Airport) *</label>
|
||||
<input type="text" id="book_in_from" name="in_from" placeholder="ICAO Code or Airport Name" required oninput="handleBookInArrivalAirportLookup(this.value)" tabindex="3">
|
||||
<div id="book-in-arrival-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="book_in_eta_time">ETA (Estimated Time of Arrival) *</label>
|
||||
<select id="book_in_eta_time" name="eta_time" required>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="book_in_notes">Notes</label>
|
||||
<textarea id="book_in_notes" name="notes" rows="3" placeholder="e.g., any special requirements"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeModal('bookInModal')">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
🛬 Book In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflight Modal -->
|
||||
<div id="overflightModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Register Overflight</h2>
|
||||
<button class="close" onclick="closeModal('overflightModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="overflight-form">
|
||||
<input type="hidden" id="overflight-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="overflight_registration">Callsign/Registration *</label>
|
||||
<input type="text" id="overflight_registration" name="registration" required oninput="handleOverflightAircraftLookup(this.value)" tabindex="1">
|
||||
<div id="overflight-aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_type">Aircraft Type</label>
|
||||
<input type="text" id="overflight_type" name="type" placeholder="e.g., C172, PA34, AA5" tabindex="2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_pob">Persons on Board</label>
|
||||
<input type="number" id="overflight_pob" name="pob" min="1" tabindex="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_departure_airfield">Departure Airfield</label>
|
||||
<input type="text" id="overflight_departure_airfield" name="departure_airfield" placeholder="ICAO Code or Airport Name" oninput="handleOverflightDepartureAirportLookup(this.value)" tabindex="4">
|
||||
<div id="overflight-departure-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_destination_airfield">Destination Airfield</label>
|
||||
<input type="text" id="overflight_destination_airfield" name="destination_airfield" placeholder="ICAO Code or Airport Name" oninput="handleOverflightDestinationAirportLookup(this.value)" tabindex="5">
|
||||
<div id="overflight-destination-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_call_dt">Time of Call *</label>
|
||||
<input type="datetime-local" id="overflight_call_dt" name="call_dt" required tabindex="6">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="overflight_notes">Notes</label>
|
||||
<textarea id="overflight_notes" name="notes" rows="3" placeholder="e.g., flight plan, special remarks" tabindex="7"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeModal('overflightModal')">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
🔄 Register Overflight
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflight Edit Modal -->
|
||||
<div id="overflightEditModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="overflight-edit-title">Overflight Details</h2>
|
||||
<button class="close" onclick="closeModal('overflightEditModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions">
|
||||
<button id="overflight-btn-qsy" class="btn btn-primary btn-sm" onclick="showOverflightQSYModal()" style="display: none;">
|
||||
📡 Mark QSY
|
||||
</button>
|
||||
<button id="overflight-btn-cancel" class="btn btn-danger btn-sm" onclick="confirmCancelOverflight()" style="display: none;">
|
||||
❌ Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="overflight-edit-form">
|
||||
<input type="hidden" id="overflight-edit-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_registration">Callsign/Registration</label>
|
||||
<input type="text" id="overflight_edit_registration" name="registration" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_type">Aircraft Type</label>
|
||||
<input type="text" id="overflight_edit_type" name="type">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_pob">Persons on Board</label>
|
||||
<input type="number" id="overflight_edit_pob" name="pob" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_departure_airfield">Departure Airfield</label>
|
||||
<input type="text" id="overflight_edit_departure_airfield" name="departure_airfield">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_destination_airfield">Destination Airfield</label>
|
||||
<input type="text" id="overflight_edit_destination_airfield" name="destination_airfield">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_call_dt">Time of Call</label>
|
||||
<input type="datetime-local" id="overflight_edit_call_dt" name="call_dt">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_status">Status</label>
|
||||
<input type="text" id="overflight_edit_status" name="status" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overflight_edit_qsy_dt">QSY Time</label>
|
||||
<input type="datetime-local" id="overflight_edit_qsy_dt" name="qsy_dt">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="overflight_edit_notes">Notes</label>
|
||||
<textarea id="overflight_edit_notes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeModal('overflightEditModal')">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Departure Edit Modal -->
|
||||
<div id="departureEditModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="departure-edit-title">Departure Details</h2>
|
||||
<button class="close" onclick="closeDepartureEditModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions">
|
||||
<button id="departure-btn-departed" class="btn btn-primary btn-sm" onclick="updateDepartureStatus('DEPARTED')" style="display: none;">
|
||||
🛫 Mark Departed
|
||||
</button>
|
||||
<button id="departure-btn-cancel" class="btn btn-danger btn-sm" onclick="updateDepartureStatus('CANCELLED')" style="display: none;">
|
||||
❌ Cancel Departure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="departure-edit-form">
|
||||
<input type="hidden" id="departure-edit-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_registration">Aircraft Registration</label>
|
||||
<input type="text" id="departure_edit_registration" name="registration">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_type">Aircraft Type</label>
|
||||
<input type="text" id="departure_edit_type" name="type">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_callsign">Callsign</label>
|
||||
<input type="text" id="departure_edit_callsign" name="callsign">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_pob">Persons on Board</label>
|
||||
<input type="number" id="departure_edit_pob" name="pob" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_out_to">Destination</label>
|
||||
<input type="text" id="departure_edit_out_to" name="out_to">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departure_edit_etd">ETD (UTC)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="departure_edit_etd_date" name="etd_date" style="flex: 1;">
|
||||
<input type="time" id="departure_edit_etd_time" name="etd_time" style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Takeoff Time (UTC)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="departure_edit_takeoff_date" name="takeoff_date" style="flex: 1;">
|
||||
<input type="time" id="departure_edit_takeoff_time" name="takeoff_time" style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="departure_edit_notes">Notes</label>
|
||||
<textarea id="departure_edit_notes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeDepartureEditModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Journal Section -->
|
||||
<div id="departure-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||||
<h3>📋 Activity Journal</h3>
|
||||
<div id="departure-journal-entries" class="journal-entries">
|
||||
Loading journal...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrival Edit Modal -->
|
||||
<div id="arrivalEditModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="arrival-edit-title">Arrival Details</h2>
|
||||
<button class="close" onclick="closeArrivalEditModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions">
|
||||
<button id="arrival-btn-landed" class="btn btn-primary btn-sm" onclick="updateArrivalStatus('LANDED')" style="display: none;">
|
||||
🛬 Mark Landed
|
||||
</button>
|
||||
<button id="arrival-btn-cancel" class="btn btn-danger btn-sm" onclick="updateArrivalStatus('CANCELLED')" style="display: none;">
|
||||
❌ Cancel Arrival
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="arrival-edit-form">
|
||||
<input type="hidden" id="arrival-edit-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="arrival_edit_registration">Aircraft Registration</label>
|
||||
<input type="text" id="arrival_edit_registration" name="registration">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="arrival_edit_type">Aircraft Type</label>
|
||||
<input type="text" id="arrival_edit_type" name="type">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="arrival_edit_callsign">Callsign</label>
|
||||
<input type="text" id="arrival_edit_callsign" name="callsign">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="arrival_edit_in_from">Origin Airport</label>
|
||||
<input type="text" id="arrival_edit_in_from" name="in_from">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="arrival_edit_pob">POB (Persons on Board)</label>
|
||||
<input type="number" id="arrival_edit_pob" name="pob" min="1">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="arrival_edit_notes">Notes</label>
|
||||
<textarea id="arrival_edit_notes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeArrivalEditModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Journal Section -->
|
||||
<div id="arrival-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||||
<h3>📋 Activity Journal</h3>
|
||||
<div id="arrival-journal-entries" class="journal-entries">
|
||||
Loading journal...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Help Modal -->
|
||||
<div id="tableHelpModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Table Information</h2>
|
||||
<button class="close" onclick="closeModal('tableHelpModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="tableHelpContent">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-info" onclick="closeModal('tableHelpModal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Modal -->
|
||||
<div id="userManagementModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>User Management</h2>
|
||||
<button class="close" onclick="closeModal('userManagementModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions" style="margin-bottom: 1rem;">
|
||||
<button class="btn btn-success" onclick="openUserCreateModal()">
|
||||
➕ Create New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="users-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading users...
|
||||
</div>
|
||||
|
||||
<div id="users-table-content" style="display: none;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="users-no-data" class="no-data" style="display: none;">
|
||||
<h3>No users found</h3>
|
||||
<p>No users are configured in the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Create/Edit Modal -->
|
||||
<div id="userModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="user-modal-title">Create User</h2>
|
||||
<button class="close" onclick="closeUserModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="user-form">
|
||||
<input type="hidden" id="user-id" name="id">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label for="user-username">Username *</label>
|
||||
<input type="text" id="user-username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="user-password">Password *</label>
|
||||
<input type="password" id="user-password" name="password" required>
|
||||
<small style="color: #666; font-size: 0.8rem;">Leave blank when editing to keep current password</small>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="user-role">Role *</label>
|
||||
<select id="user-role" name="role" required>
|
||||
<option value="READ_ONLY">Read Only - View only access</option>
|
||||
<option value="OPERATOR">Operator - PPR management access</option>
|
||||
<option value="ADMINISTRATOR">Administrator - Full access</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeUserModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
💾 Save User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change User Password Modal -->
|
||||
<div id="changePasswordModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="change-password-title">Change User Password</h2>
|
||||
<button class="close" onclick="closeChangePasswordModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="change-password-form">
|
||||
<div class="form-group full-width">
|
||||
<label for="change-password-username" style="font-weight: bold;">Username</label>
|
||||
<input type="text" id="change-password-username" name="username" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="change-password-new">New Password *</label>
|
||||
<input type="password" id="change-password-new" name="new_password" required>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="change-password-confirm">Confirm New Password *</label>
|
||||
<input type="password" id="change-password-confirm" name="confirm_password" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeChangePasswordModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
🔐 Change Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<!-- User Aircraft Management Modal -->
|
||||
<div id="userAircraftModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 1000px;">
|
||||
<div class="modal-header">
|
||||
<h2>User Aircraft Management</h2>
|
||||
<button class="close" onclick="closeModal('userAircraftModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-actions" style="margin-bottom: 1rem;">
|
||||
<div class="form-group" style="display: inline-block; margin-right: 1rem;">
|
||||
<label for="user-aircraft-search" style="display: inline; margin-right: 0.5rem;">Search:</label>
|
||||
<input type="text" id="user-aircraft-search" placeholder="Filter by registration or type..." style="width: 250px;">
|
||||
</div>
|
||||
<button class="btn btn-info" onclick="loadUserAircraft()">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="user-aircraft-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading user aircraft...
|
||||
</div>
|
||||
|
||||
<div id="user-aircraft-table-content" style="display: none;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Registration</th>
|
||||
<th>Type</th>
|
||||
<th>Added By</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-aircraft-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="user-aircraft-no-data" class="no-data" style="display: none;">
|
||||
<h3>No user aircraft found</h3>
|
||||
<p>No custom aircraft types have been saved yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Aircraft Edit Modal -->
|
||||
<div id="userAircraftEditModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="user-aircraft-edit-title">Edit User Aircraft</h2>
|
||||
<button class="close" onclick="closeModal('userAircraftEditModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="user-aircraft-edit-form">
|
||||
<div class="form-group full-width">
|
||||
<label for="edit-aircraft-registration">Registration *</label>
|
||||
<input type="text" id="edit-aircraft-registration" name="registration" required readonly style="background-color: #f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="edit-aircraft-type">Aircraft Type *</label>
|
||||
<input type="text" id="edit-aircraft-type" name="type_code" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeModal('userAircraftEditModal')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
💾 Save Changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteUserAircraft()">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp Modal for Landing/Departure -->
|
||||
<div id="timestampModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="timestamp-modal-title">Confirm Landing Time</h2>
|
||||
<button class="close" onclick="closeTimestampModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="timestamp-form">
|
||||
<div class="form-group">
|
||||
<label for="event-timestamp">Event Time (UTC) *</label>
|
||||
<input type="datetime-local" id="event-timestamp" name="timestamp" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeTimestampModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success" id="timestamp-submit-btn">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Circuit Modal for recording touch-and-go events -->
|
||||
<div id="circuitModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>Record Circuit (Touch & Go)</h2>
|
||||
<button class="close" onclick="closeCircuitModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="circuit-form">
|
||||
<div class="form-group">
|
||||
<label for="circuit-timestamp">Circuit Time (UTC) *</label>
|
||||
<input type="datetime-local" id="circuit-timestamp" name="timestamp" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-info" onclick="closeCircuitModal()">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Record Circuit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+3020
File diff suppressed because it is too large
Load Diff
+270
@@ -0,0 +1,270 @@
|
||||
(function () {
|
||||
const actionMap = {
|
||||
'new-ppr': "openNewPPRModal",
|
||||
'book-out': "openLocalFlightModal",
|
||||
'book-in': "openBookInModal",
|
||||
overflight: "openOverflightModal",
|
||||
'user-aircraft': "openUserAircraftModal",
|
||||
'user-management': "openUserManagementModal"
|
||||
};
|
||||
|
||||
function injectTopbarStyles() {
|
||||
if (document.getElementById('shared-topbar-styles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'shared-topbar-styles';
|
||||
style.textContent = `
|
||||
.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;
|
||||
}
|
||||
.top-bar .title { order: 2; flex: 1; text-align: center; }
|
||||
.top-bar .title h1 { margin: 0; font-size: 1.5rem; }
|
||||
.top-bar .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;
|
||||
}
|
||||
.top-bar .btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.top-bar .btn-success { background-color: #27ae60; color: white; }
|
||||
.top-bar .btn-warning { background-color: #f39c12; color: white; }
|
||||
.dropdown { position: relative; display: inline-block; }
|
||||
.dropdown-toggle { white-space: nowrap; }
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
min-width: 220px;
|
||||
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.active { display: block; }
|
||||
.dropdown-menu a {
|
||||
color: #333;
|
||||
padding: 0.75rem 1.2rem;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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; }
|
||||
.shortcut { font-size: 0.8rem; color: #999; margin-left: 1rem; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function actionLink(label, action, shortcut, id = '') {
|
||||
const shortcutText = shortcut ? `<span class="shortcut">(${shortcut})</span>` : '';
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
const hidden = id === 'user-management-dropdown' ? ' style="display: none;"' : '';
|
||||
return `<a href="#" data-topbar-action="${action}"${idAttr}${hidden}>${label} ${shortcutText}</a>`;
|
||||
}
|
||||
|
||||
function navLink(label, href) {
|
||||
return `<a href="${href}">${label}</a>`;
|
||||
}
|
||||
|
||||
function normalizeTopbar() {
|
||||
const topbar = document.querySelector('.top-bar');
|
||||
if (!topbar || topbar.dataset.sharedTopbar === 'true') return;
|
||||
|
||||
injectTopbarStyles();
|
||||
|
||||
const existingTitle = topbar.querySelector('#tower-title, .title h1, h1');
|
||||
const path = window.location.pathname.replace(/\/$/, '') || '/';
|
||||
const titleByPath = {
|
||||
'/admin': '✈️ Swansea Tower',
|
||||
'/atc': '✈️ Swansea Tower - ATC View',
|
||||
'/reports': '📊 PPR Reports',
|
||||
'/movements': '📈 Movements',
|
||||
'/drone-requests': 'Drone Requests',
|
||||
'/bulk-log': '🧾 Bulk Flight Log',
|
||||
'/journal': '📔 Journal Log'
|
||||
};
|
||||
const titleText = titleByPath[path] || (existingTitle ? existingTitle.textContent.trim() : 'Tower Ops');
|
||||
const existingUser = topbar.querySelector('#current-user');
|
||||
const username = existingUser ? existingUser.textContent.trim() : 'Loading...';
|
||||
|
||||
topbar.dataset.sharedTopbar = 'true';
|
||||
topbar.innerHTML = `
|
||||
<div class="title">
|
||||
<h1 id="tower-title">${titleText}</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-success dropdown-toggle" id="actionsDropdownBtn">📋 Actions</button>
|
||||
<div class="dropdown-menu" id="actionsDropdownMenu">
|
||||
${actionLink('➕ New PPR', 'new-ppr', 'N')}
|
||||
${actionLink('🛫 Book Out', 'book-out', 'L')}
|
||||
${actionLink('🛬 Book In', 'book-in', 'I')}
|
||||
${actionLink('🔄 Overflight', 'overflight', 'O')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">⚙️ Menu</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
${navLink('🏠 Admin View', '/admin')}
|
||||
${navLink('🎛️ ATC View', '/atc')}
|
||||
${navLink('📊 Reports', '/reports')}
|
||||
${navLink('🛸 Drone Requests', '/drone-requests')}
|
||||
${navLink('📔 Journal Log', '/journal')}
|
||||
${actionLink('✈️ User Aircraft', 'user-aircraft', '', 'user-aircraft-dropdown')}
|
||||
${actionLink('👥 User Management', 'user-management', '', 'user-management-dropdown')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">${username || 'Loading...'}</span> |
|
||||
<a href="#" data-topbar-logout style="color: white;">Logout</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function closeDropdowns(except = null) {
|
||||
document.querySelectorAll('.dropdown-menu.active').forEach(menu => {
|
||||
if (menu !== except) menu.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
const fnName = actionMap[action];
|
||||
if (fnName && typeof window[fnName] === 'function') {
|
||||
if (action === 'book-out') {
|
||||
window[fnName]('LOCAL');
|
||||
} else {
|
||||
window[fnName]();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/admin?action=${encodeURIComponent(action)}`;
|
||||
}
|
||||
|
||||
function handleDeferredAction() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const action = params.get('action');
|
||||
if (!action) return;
|
||||
|
||||
window.setTimeout(() => {
|
||||
runAction(action);
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function updateRoleVisibility() {
|
||||
const token = localStorage.getItem('ppr_access_token');
|
||||
const userManagement = document.getElementById('user-management-dropdown');
|
||||
const userAircraft = document.getElementById('user-aircraft-dropdown');
|
||||
|
||||
if (!token || (!userManagement && !userAircraft)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/test-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const user = await response.json();
|
||||
const role = (user.role || '').toUpperCase();
|
||||
if (role === 'ADMINISTRATOR') {
|
||||
if (userManagement) userManagement.style.display = 'flex';
|
||||
if (userAircraft) userAircraft.style.display = 'flex';
|
||||
} else if (role === 'OPERATOR') {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
if (userAircraft) userAircraft.style.display = 'flex';
|
||||
} else {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
if (userAircraft) userAircraft.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('.dropdown-toggle');
|
||||
const action = event.target.closest('[data-topbar-action]');
|
||||
const logout = event.target.closest('[data-topbar-logout]');
|
||||
|
||||
if (toggle) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||
const willOpen = !menu.classList.contains('active');
|
||||
closeDropdowns(menu);
|
||||
menu.classList.toggle('active', willOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeDropdowns();
|
||||
runAction(action.dataset.topbarAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (logout) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (typeof window.logout === 'function') {
|
||||
window.logout();
|
||||
} else {
|
||||
localStorage.removeItem('ppr_access_token');
|
||||
localStorage.removeItem('ppr_username');
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest('.dropdown')) {
|
||||
closeDropdowns();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
normalizeTopbar();
|
||||
updateRoleVisibility();
|
||||
handleDeferredAction();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user