Compare commits

...

29 Commits

Author SHA1 Message Date
James Pattinson 870bc0649b Emergency fixes on prod 2026-06-28 08:35:39 +00:00
jamesp 5e12561fb2 Admin rename and ATC fixes 2026-06-27 06:07:02 -04:00
jamesp f33c12f541 Drone req handling update 2026-06-22 05:58:26 -04:00
jamesp 05e7859447 Website contact form endpoint 2026-06-21 17:45:31 -04:00
jamesp a3f1a10bf5 Drone management improvements 2026-06-21 17:26:42 -04:00
jamesp 5e33c1d47b Adding e2e testing 2026-06-20 10:43:08 -04:00
jamesp 10ab215396 Menu cleanup 2026-06-20 08:04:37 -04:00
jamesp a9b5ec67ba Admin cleanup 2026-06-20 07:38:09 -04:00
jamesp 733e9b426f Add FRZ 2026-06-20 06:41:40 -04:00
jamesp 044ce40e69 Remove redis 2026-06-20 04:09:38 -04:00
jamesp fc394b8555 Flash out API test suite 2026-06-20 04:01:24 -04:00
jamesp 78d738b0ee Drone flights and Bulk Logging WIPs 2026-06-19 17:27:33 -04:00
jamesp 1952b89ecf PPR ACK and Bulk Logging start 2026-06-15 15:45:58 -04:00
jamesp 7b2de645db Major refactor WIP 2026-04-03 11:13:44 -04:00
jamesp dee58e0aae Journaling improvements 2026-04-03 03:57:20 -04:00
jamesp 2dce14507b Booking form fixes 2026-03-25 17:25:26 -04:00
jamesp 9867156334 Many more states WIP 2026-03-25 13:16:36 -04:00
jamesp eb2321ef40 Before refactor 2026-03-24 13:35:29 -04:00
jamesp bb6597ff76 Major WIP state machine 2026-03-24 11:22:20 -04:00
jamesp 423023d3d9 adding flow doc 2026-03-24 04:48:06 -04:00
jamesp fd0e521186 List and edit user aircraft 2026-03-23 13:09:49 -04:00
jamesp d2c9bc0370 Unknown type supprt 2026-03-23 12:47:08 -04:00
jamesp bddbe1451f Little tidy 2026-02-20 16:50:03 -05:00
jamesp 785562407a localStorage for booking out 2026-02-20 16:42:06 -05:00
jamesp 5bb229ad78 Oops 2026-02-20 12:23:09 -05:00
jamesp 8a2dd5544c ignore QR 2026-02-20 12:21:12 -05:00
jamesp 3a4085afc6 Booking out QR code 2026-02-20 12:19:21 -05:00
jamesp a43cf9b732 Merge pull request 'Pilot self-bookout' (#6) from local-flights into main
Reviewed-on: #6
2026-02-20 11:59:25 -05:00
jamesp 7f4e4a8459 Pilot self-bookout 2026-02-20 11:52:43 -05:00
112 changed files with 19197 additions and 3958 deletions
+1
View File
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
MAIL_PASSWORD=your_mail_password_here MAIL_PASSWORD=your_mail_password_here
MAIL_FROM=your_mail_from_address_here MAIL_FROM=your_mail_from_address_here
MAIL_FROM_NAME=your_mail_from_name_here MAIL_FROM_NAME=your_mail_from_name_here
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
# Application settings # Application settings
BASE_URL=your_base_url_here BASE_URL=your_base_url_here
+6
View File
@@ -1,3 +1,5 @@
web/assets/booking-qr.png
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -83,3 +85,7 @@ coverage.xml
*.cover *.cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
# Playwright artifacts
playwright-report/
test-results/
+39 -4
View File
@@ -6,7 +6,6 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
- **Backend**: FastAPI with Python 3.11 - **Backend**: FastAPI with Python 3.11
- **Database**: MySQL 8.0 - **Database**: MySQL 8.0
- **Cache**: Redis 7
- **Container**: Docker & Docker Compose - **Container**: Docker & Docker Compose
## Features ## Features
@@ -63,7 +62,7 @@ The container automatically handles:
- Database connection verification - Database connection verification
- Schema creation/migration (Alembic) - Schema creation/migration (Alembic)
- Reference data seeding (if needed) - Reference data seeding (if needed)
- Production server startup (4 workers) - Production server startup (single worker for in-process WebSocket broadcasts)
**Monitor deployment:** **Monitor deployment:**
```bash ```bash
@@ -181,11 +180,48 @@ docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
``` ```
### Testing ### 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 ```bash
cd backend 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
```
### End-to-End Testing
Browser e2e tests use pytest plus Playwright. The recommended path is the containerized runner, which joins the same Compose network as the app and opens the web service at `http://web`.
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
Authenticated browser tests are skipped unless `E2E_ADMIN_USERNAME` and `E2E_ADMIN_PASSWORD` are supplied. See [`tests/e2e/README.md`](./tests/e2e/README.md) for credential examples, host-run instructions, and guidance for adding specs.
The e2e Compose override uses a separate MySQL container and volume, so tests do not use the normal dev/prod database configured in `.env`. It is still a real MySQL database, but isolated for e2e.
E2e reports are written to `test-results/e2e-report.html` and `test-results/e2e-junit.xml`.
## Additional Features ## Additional Features
### Email Notifications ### Email Notifications
@@ -266,7 +302,6 @@ This ensures consistency across different time zones and complies with aviation
- Database connection pooling - Database connection pooling
- Indexed columns for fast queries - Indexed columns for fast queries
- Redis caching (ready for implementation)
- Async/await for non-blocking operations - Async/await for non-blocking operations
## Monitoring ## Monitoring
+16
View File
@@ -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
View File
@@ -3,11 +3,12 @@ FROM python:3.11-slim
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies including qrencode
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config \
qrencode \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # 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')")
+67
View File
@@ -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,89 @@
"""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',
'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')
+5 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter 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, contact_requests
api_router = APIRouter() api_router = APIRouter()
@@ -11,6 +11,10 @@ api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"]
api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"]) api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"])
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"]) api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"]) 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(contact_requests.router, prefix="/contact-requests", tags=["contact_requests"])
api_router.include_router(public.router, prefix="/public", tags=["public"]) 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(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"]) api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
+147 -4
View File
@@ -1,9 +1,10 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.deps import get_db, get_current_active_user from app.api.deps import get_db, get_current_active_user
from app.models.ppr import Aircraft from app.models.ppr import Aircraft, UserAircraft
from app.schemas.ppr import Aircraft as AircraftSchema from app.schemas.ppr import Aircraft as AircraftSchema, UserAircraftCreate, UserAircraft as UserAircraftSchema
from app.models.ppr import User from app.models.ppr import User
router = APIRouter() router = APIRouter()
@@ -18,6 +19,7 @@ async def lookup_aircraft_by_registration(
""" """
Lookup aircraft by registration (clean match). Lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching. 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 the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper() 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: if len(clean_input) < 4:
return [] 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_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%") Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all() ).limit(10).all()
@@ -42,6 +66,7 @@ async def public_lookup_aircraft_by_registration(
Public lookup aircraft by registration (clean match). Public lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching. Removes non-alphanumeric characters from input for matching.
No authentication required. No authentication required.
Checks user_aircraft table first, then aircraft table.
""" """
# Clean the input registration (remove non-alphanumeric characters) # Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper() 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: if len(clean_input) < 4:
return [] 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_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%") Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all() ).limit(10).all()
@@ -82,3 +128,100 @@ async def search_aircraft(
).limit(limit).all() ).limit(limit).all()
return aircraft_list 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"}
+4 -3
View File
@@ -38,12 +38,12 @@ async def create_arrival(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Create a new arrival record""" """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 # Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({ await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in", "type": "arrival_inbound",
"data": { "data": {
"id": arrival.id, "id": arrival.id,
"registration": arrival.registration, "registration": arrival.registration,
@@ -159,7 +159,8 @@ async def cancel_arrival(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Cancel an arrival record""" """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: if not arrival:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+3 -3
View File
@@ -87,7 +87,7 @@ async def create_user(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered" 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 return user
@@ -105,7 +105,7 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail="User not found"
) )
user = crud_user.update(db, db_obj=user, obj_in=user_in) user = crud_user.update(db, db_obj=user, obj_in=user_in, admin_user=current_user.username)
return user return user
@@ -123,5 +123,5 @@ async def change_user_password(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User 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 return user
+29 -3
View File
@@ -33,6 +33,17 @@ async def get_circuits_by_flight(
return circuits 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) @router.post("/", response_model=Circuit)
async def create_circuit( async def create_circuit(
request: Request, request: Request,
@@ -40,8 +51,21 @@ async def create_circuit(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Record a new circuit (touch and go) for a local flight""" """Record a new circuit (touch and go) for a local flight or arrival"""
circuit = crud_circuit.create(db, obj_in=circuit_in) # 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 # Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
@@ -93,6 +117,7 @@ async def update_circuit(
@router.delete("/{circuit_id}") @router.delete("/{circuit_id}")
async def delete_circuit( async def delete_circuit(
request: Request,
circuit_id: int, circuit_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
@@ -104,5 +129,6 @@ async def delete_circuit(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Circuit record 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"} return {"detail": "Circuit record deleted"}
@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from app.core.email import email_service
from app.core.utils import get_client_ip
from app.schemas.contact_request import ContactRequestCreate, ContactRequestReceipt
router = APIRouter()
CONTACT_REQUEST_RECIPIENT = "tower@swansea-airport.wales"
@router.post("/public", response_model=ContactRequestReceipt)
async def create_public_contact_request(
contact_request: ContactRequestCreate,
request: Request,
):
submitted_at = datetime.now(timezone.utc)
client_ip = get_client_ip(request)
print(
"Public contact request received "
f"at={submitted_at.isoformat()} "
f"type={contact_request.enquiry_type.value} "
f"name={contact_request.name!r} "
f"email={contact_request.email} "
f"source={contact_request.source_page or '-'} "
f"ip={client_ip}"
)
await email_service.send_email(
to_email=CONTACT_REQUEST_RECIPIENT,
subject=f"Website contact: {contact_request.subject}",
template_name="contact_request.html",
reply_to=f"{contact_request.name} <{contact_request.email}>",
template_vars={
"submitted_at": submitted_at.strftime("%Y-%m-%d %H:%M UTC"),
"client_ip": client_ip,
"name": contact_request.name,
"email": contact_request.email,
"phone": contact_request.phone,
"enquiry_type": contact_request.enquiry_type.value,
"subject": contact_request.subject,
"message": contact_request.message,
"source_page": contact_request.source_page,
},
)
return ContactRequestReceipt()
+3 -2
View File
@@ -38,7 +38,7 @@ async def create_departure(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Create a new departure record""" """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 # Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
@@ -159,7 +159,8 @@ async def cancel_departure(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Cancel a departure record""" """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: if not departure:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+356
View File
@@ -0,0 +1,356 @@
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,
DroneRequestPublicSubmission,
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_tower_notification(drone_request):
tower_email = settings.drone_request_tower_email or settings.mail_from
await email_service.send_email(
to_email=tower_email,
subject=f"Drone flight request awaiting review {drone_request.reference_number}",
template_name="drone_request_tower_notification.html",
template_vars={
"reference_number": drone_request.reference_number,
"operator_name": drone_request.operator_name,
"operator_id": drone_request.operator_id,
"flyer_name": drone_request.flyer_name,
"flyer_id": drone_request.flyer_id,
"email": drone_request.email,
"phone": drone_request.phone,
"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,
"inside_frz": "Yes" if drone_request.location_inside_frz else "No",
"notes": drone_request.applicant_notes,
"requests_url": f"{settings.base_url}/drone-requests",
},
reply_to=f"{drone_request.operator_name} <{drone_request.email}>",
)
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,
},
)
def _public_submission_response(drone_request):
payload = DroneRequest.model_validate(drone_request, from_attributes=True).model_dump(mode="json")
payload["request_id"] = drone_request.reference_number
payload["secure_link"] = f"{settings.base_url}/drone-request.html?token={drone_request.public_token}"
return payload
@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=DroneRequestPublicSubmission)
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)
await _send_drone_tower_notification(drone_request)
return _public_submission_response(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.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.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)
+69 -17
View File
@@ -4,11 +4,79 @@ from app.api import deps
from app.crud.crud_journal import journal from app.crud.crud_journal import journal
from app.models.journal import EntityType from app.models.journal import EntityType
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse 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 = 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) @router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
async def get_entity_journal( async def get_entity_journal(
entity_type: str, entity_type: str,
@@ -45,19 +113,3 @@ async def get_entity_journal(
entries=entries, entries=entries,
total_entries=len(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
+3 -2
View File
@@ -39,7 +39,7 @@ async def create_local_flight(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Create a new local flight record (book out)""" """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 # Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
@@ -160,7 +160,8 @@ async def cancel_local_flight(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Cancel a local flight record""" """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: if not flight:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+647
View File
@@ -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
+117 -3
View File
@@ -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.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_ppr import ppr as crud_ppr
from app.crud.crud_journal import journal as crud_journal 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.models.ppr import User
from app.core.utils import get_client_ip from app.core.utils import get_client_ip
from app.core.email import email_service from app.core.email import email_service
@@ -235,6 +240,41 @@ async def update_ppr_status(
return ppr 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) @router.delete("/{ppr_id}", response_model=PPR)
async def delete_ppr( async def delete_ppr(
request: Request, request: Request,
@@ -358,7 +398,7 @@ async def cancel_ppr_public(
return cancelled_ppr 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( async def get_ppr_journal(
ppr_id: int, ppr_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -373,4 +413,78 @@ async def get_ppr_journal(
detail="PPR record not found" 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 "")
)
}
+49 -30
View File
@@ -59,28 +59,36 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
'isLocalFlight': False '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 # Get today's date boundaries
today = date.today() today = date.today()
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today + timedelta(days=1), 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 # Convert local flights to match the PPR format for display
for flight in local_flights: for flight in local_flights:
# Only include flights booked out today # Only include flights booked out today
if not (today_start <= flight.created_dt < today_end): if not (today_start <= flight.created_dt < today_end):
continue continue
if flight.status not in airborne_local_statuses:
continue
# Calculate ETA from departed_dt + duration (if both are available) # Calculate ETA from actual takeoff/departure + duration, falling back to ETD.
eta = flight.departed_dt departure_time = flight.takeoff_dt or flight.departed_dt or flight.etd
if flight.departed_dt and flight.duration: eta = departure_time
eta = flight.departed_dt + timedelta(minutes=flight.duration) if departure_time and flight.duration:
eta = departure_time + timedelta(minutes=flight.duration)
arrivals_list.append({ arrivals_list.append({
'ac_call': flight.callsign or flight.registration, 'ac_call': flight.callsign or flight.registration,
@@ -89,7 +97,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
'in_from': None, 'in_from': None,
'eta': eta, 'eta': eta,
'landed_dt': None, 'landed_dt': None,
'status': 'DEPARTED', 'status': flight.status.value,
'isLocalFlight': True, 'isLocalFlight': True,
'flight_type': flight.flight_type.value 'flight_type': flight.flight_type.value
}) })
@@ -97,11 +105,11 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
# Add booked-in arrivals # Add booked-in arrivals
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000) booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
for arrival in booked_in_arrivals: for arrival in booked_in_arrivals:
# Only include BOOKED_IN and LANDED arrivals # Only include BOOKED_IN, INBOUND and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED): if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND, ArrivalStatus.LANDED):
continue continue
# For BOOKED_IN, only include those created today # For BOOKED_IN and INBOUND, only include those created today
if arrival.status == ArrivalStatus.BOOKED_IN: if arrival.status in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND):
if not (today_start <= arrival.created_dt < today_end): if not (today_start <= arrival.created_dt < today_end):
continue continue
# For LANDED, only include those landed today # For LANDED, only include those landed today
@@ -143,23 +151,26 @@ async def get_public_departures(db: Session = Depends(get_db)):
'isDeparture': False 'isDeparture': False
}) })
# Add local flights with BOOKED_OUT status that were booked out today
local_flights = crud_local_flight.get_multi(
db,
status=LocalFlightStatus.BOOKED_OUT,
limit=1000
)
# Get today's date boundaries # Get today's date boundaries
today = date.today() today = date.today()
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today + timedelta(days=1), 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 # Convert local flights to match the PPR format for display
for flight in local_flights: for flight in local_flights:
# Only include flights booked out today # Only include flights booked out today
if not (today_start <= flight.created_dt < today_end): if not (today_start <= flight.created_dt < today_end):
continue continue
if flight.status not in local_departure_statuses:
continue
departures_list.append({ departures_list.append({
'ac_call': flight.callsign or flight.registration, 'ac_call': flight.callsign or flight.registration,
'ac_reg': flight.registration, 'ac_reg': flight.registration,
@@ -167,16 +178,16 @@ async def get_public_departures(db: Session = Depends(get_db)):
'out_to': None, 'out_to': None,
'etd': flight.etd or flight.created_dt, 'etd': flight.etd or flight.created_dt,
'departed_dt': None, 'departed_dt': None,
'status': 'BOOKED_OUT', 'status': 'CONTACT' if flight.status == LocalFlightStatus.GROUND else 'BOOKED_OUT',
'isLocalFlight': True, 'isLocalFlight': True,
'flight_type': flight.flight_type.value, 'flight_type': flight.flight_type.value,
'isDeparture': False '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( departures_to_airports = crud_departure.get_multi(
db, db,
status=DepartureStatus.BOOKED_OUT, status=None, # Get all statuses
limit=1000 limit=1000
) )
@@ -187,17 +198,25 @@ async def get_public_departures(db: Session = Depends(get_db)):
# Convert departures to match the format for display # Convert departures to match the format for display
for dep in departures_to_airports: for dep in departures_to_airports:
# Only include departures booked out today # Only include departures booked out today and not yet departed
if not (today_start <= dep.created_dt < today_end): if not (today_start <= dep.created_dt < today_end) or dep.status == DepartureStatus.DEPARTED:
continue 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({ departures_list.append({
'ac_call': dep.callsign or dep.registration, 'ac_call': dep.callsign or dep.registration,
'ac_reg': dep.registration, 'ac_reg': dep.registration,
'ac_type': dep.type, 'ac_type': dep.type,
'out_to': dep.out_to, 'out_to': dep.out_to,
'etd': dep.etd or dep.created_dt, 'etd': dep.etd or dep.created_dt,
'departed_dt': None, 'departed_dt': dep.departed_dt,
'status': 'BOOKED_OUT', 'status': display_status,
'isLocalFlight': False, 'isLocalFlight': False,
'isDeparture': True 'isDeparture': True
}) })
+205
View File
@@ -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
+3 -3
View File
@@ -1,5 +1,4 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -22,6 +21,7 @@ class Settings(BaseSettings):
mail_password: str mail_password: str
mail_from: str mail_from: str
mail_from_name: str mail_from_name: str
drone_request_tower_email: str | None = None
# Application settings # Application settings
api_v1_str: str = "/api/v1" api_v1_str: str = "/api/v1"
@@ -33,8 +33,8 @@ class Settings(BaseSettings):
top_bar_base_color: str = "#2c3e50" top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc. environment: str = "production" # production, development, staging, etc.
# Redis settings (for future use) # Public booking settings
redis_url: Optional[str] = None allow_public_booking: bool = False # Enable/disable public flight booking
class Config: class Config:
env_file = ".env" env_file = ".env"
+10 -1
View File
@@ -19,7 +19,14 @@ class EmailService:
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates') template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_dir)) self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict): async def send_email(
self,
to_email: str,
subject: str,
template_name: str,
template_vars: dict,
reply_to: str | None = None,
):
# Render the template # Render the template
template = self.jinja_env.get_template(template_name) template = self.jinja_env.get_template(template_name)
html_content = template.render(**template_vars) html_content = template.render(**template_vars)
@@ -29,6 +36,8 @@ class EmailService:
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = f"{self.from_name} <{self.from_email}>" msg['From'] = f"{self.from_name} <{self.from_email}>"
msg['To'] = to_email msg['To'] = to_email
if reply_to:
msg['Reply-To'] = reply_to
# Attach HTML content # Attach HTML content
html_part = MIMEText(html_content, 'html') html_part = MIMEText(html_content, 'html')
+156
View File
@@ -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
+92 -7
View File
@@ -6,6 +6,9 @@ from app.models.arrival import Arrival, ArrivalStatus
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
from app.models.journal import EntityType from app.models.journal import EntityType
from app.crud.crud_journal import journal 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: class CRUDArrival:
@@ -24,6 +27,16 @@ class CRUDArrival:
query = db.query(Arrival) query = db.query(Arrival)
if status: if 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) query = query.filter(Arrival.status == status)
if date_from: if date_from:
@@ -35,27 +48,48 @@ class CRUDArrival:
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all() return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[Arrival]: 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() today = date.today()
return db.query(Arrival).filter( return db.query(Arrival).filter(
and_( and_(
func.date(Arrival.created_dt) == today, func.date(Arrival.created_dt) == today,
or_( or_(
Arrival.status == ArrivalStatus.BOOKED_IN, Arrival.status == ArrivalStatus.BOOKED_IN,
Arrival.status == ArrivalStatus.INBOUND,
Arrival.status == ArrivalStatus.LANDED Arrival.status == ArrivalStatus.LANDED
) )
) )
).order_by(Arrival.created_dt).all() ).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( db_obj = Arrival(
**obj_in.dict(), **obj_in.dict(exclude={'submitted_via'}),
created_by=created_by, created_by=created_by,
status=ArrivalStatus.BOOKED_IN status=initial_status,
submitted_via=submitted_via
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival: 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 old_status = db_obj.status
db_obj.status = status db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp: # Set timestamps based on status
db_obj.landed_dt = timestamp 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.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 # Log status change in journal
journal.log_change( journal.log_change(
db, db,
@@ -132,15 +205,27 @@ class CRUDArrival:
return db_obj 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) db_obj = self.get(db, arrival_id)
if not db_obj: if not db_obj:
return None return None
old_status = db_obj.status
db_obj.status = ArrivalStatus.CANCELLED db_obj.status = ArrivalStatus.CANCELLED
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
+102 -3
View File
@@ -4,6 +4,13 @@ from sqlalchemy import desc
from datetime import datetime from datetime import datetime
from app.models.circuit import Circuit from app.models.circuit import Circuit
from app.schemas.circuit import CircuitCreate, CircuitUpdate 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: class CRUDCircuit:
@@ -16,6 +23,12 @@ class CRUDCircuit:
Circuit.local_flight_id == local_flight_id Circuit.local_flight_id == local_flight_id
).order_by(Circuit.circuit_timestamp).all() ).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( def get_multi(
self, self,
db: Session, db: Session,
@@ -24,30 +37,116 @@ class CRUDCircuit:
) -> List[Circuit]: ) -> List[Circuit]:
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all() 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( db_obj = Circuit(
local_flight_id=obj_in.local_flight_id, local_flight_id=obj_in.local_flight_id,
arrival_id=obj_in.arrival_id,
circuit_timestamp=obj_in.circuit_timestamp circuit_timestamp=obj_in.circuit_timestamp
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 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) obj_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in obj_data.items(): for field, value in obj_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) setattr(db_obj, field, value)
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 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) circuit = self.get(db, circuit_id)
if circuit: 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.delete(circuit)
db.commit() 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 True
return False return False
+75 -6
View File
@@ -6,6 +6,9 @@ from app.models.departure import Departure, DepartureStatus
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
from app.models.journal import EntityType from app.models.journal import EntityType
from app.crud.crud_journal import journal 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: class CRUDDeparture:
@@ -47,15 +50,47 @@ class CRUDDeparture:
) )
).order_by(Departure.created_dt).all() ).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( db_obj = Departure(
**obj_in.dict(), **obj_data,
arrival_id=arrival_id,
created_by=created_by, created_by=created_by,
status=DepartureStatus.BOOKED_OUT status=initial_status,
contact_dt=contact_dt,
submitted_via=submitted_via
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure: 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 old_status = db_obj.status
db_obj.status = status db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp: # Set timestamps based on status
db_obj.departed_dt = timestamp 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.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 # Log status change in journal
journal.log_change( journal.log_change(
db, db,
@@ -132,15 +189,27 @@ class CRUDDeparture:
return db_obj 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) db_obj = self.get(db, departure_id)
if not db_obj: if not db_obj:
return None return None
old_status = db_obj.status
db_obj.status = DepartureStatus.CANCELLED db_obj.status = DepartureStatus.CANCELLED
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
+189
View File
@@ -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()
+37 -1
View File
@@ -1,7 +1,8 @@
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from app.models.journal import JournalEntry, EntityType from app.models.journal import JournalEntry, EntityType
from datetime import datetime from datetime import datetime, date
class CRUDJournal: class CRUDJournal:
@@ -58,6 +59,41 @@ class CRUDJournal:
JournalEntry.user == user JournalEntry.user == user
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all() ).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 # Convenience methods for backward compatibility with PPR journal
def log_ppr_change( def log_ppr_change(
self, self,
+91 -5
View File
@@ -7,6 +7,9 @@ from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, Local
from app.models.journal import EntityType from app.models.journal import EntityType
from app.models.circuit import Circuit from app.models.circuit import Circuit
from app.crud.crud_journal import journal 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: class CRUDLocalFlight:
@@ -26,6 +29,16 @@ class CRUDLocalFlight:
query = db.query(LocalFlight) query = db.query(LocalFlight)
if status: if 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) query = query.filter(LocalFlight.status == status)
if flight_type: if flight_type:
@@ -74,15 +87,35 @@ class CRUDLocalFlight:
) )
).order_by(LocalFlight.created_dt).all() ).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( db_obj = LocalFlight(
**obj_in.dict(), **obj_in.dict(),
created_by=created_by, created_by=created_by,
status=LocalFlightStatus.BOOKED_OUT status=initial_status,
submitted_via=submitted_via
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight: 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 old_status = db_obj.status
db_obj.status = 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 # Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow() 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 and not db_obj.departed_dt:
db_obj.departed_dt = current_time 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 db_obj.landed_dt = current_time
# Count circuits from the circuits table and populate the circuits column # Count circuits from the circuits table and populate the circuits column
circuit_count = db.query(func.count(Circuit.id)).filter( circuit_count = db.query(func.count(Circuit.id)).filter(
@@ -156,10 +197,44 @@ class CRUDLocalFlight:
).scalar() ).scalar()
db_obj.circuits = circuit_count 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
if not db_obj.departed_dt:
db_obj.departed_dt = current_time
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 # Log status change in journal
journal.log_change( journal.log_change(
db, db,
@@ -172,13 +247,24 @@ class CRUDLocalFlight:
return db_obj 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) db_obj = self.get(db, flight_id)
if db_obj: if db_obj:
old_status = db_obj.status
db_obj.status = LocalFlightStatus.CANCELLED db_obj.status = LocalFlightStatus.CANCELLED
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
+92
View File
@@ -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()
+18
View File
@@ -6,6 +6,9 @@ from app.models.overflight import Overflight, OverflightStatus
from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate
from app.models.journal import EntityType from app.models.journal import EntityType
from app.crud.crud_journal import journal 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: class CRUDOverflight:
@@ -57,6 +60,21 @@ class CRUDOverflight:
db.commit() db.commit()
db.refresh(db_obj) 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 # Log creation in journal
journal.log_change( journal.log_change(
db, db,
+31
View File
@@ -169,6 +169,36 @@ class CRUDPPR:
return db_obj 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]: 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) db_obj = self.get(db, ppr_id)
if db_obj: if db_obj:
@@ -182,6 +212,7 @@ class CRUDPPR:
# Log the deletion in journal # Log the deletion in journal
crud_journal.log_change( crud_journal.log_change(
db, db,
EntityType.PPR,
db_obj.id, db_obj.id,
f"PPR marked as DELETED (was {old_status.value})", f"PPR marked as DELETED (was {old_status.value})",
user, user,
+45 -3
View File
@@ -3,6 +3,8 @@ from sqlalchemy.orm import Session
from app.models.ppr import User from app.models.ppr import User
from app.schemas.ppr import UserCreate, UserUpdate from app.schemas.ppr import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password 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: class CRUDUser:
@@ -15,7 +17,7 @@ class CRUDUser:
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]: def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
return db.query(User).offset(skip).limit(limit).all() 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) hashed_password = get_password_hash(obj_in.password)
db_obj = User( db_obj = User(
username=obj_in.username, username=obj_in.username,
@@ -25,17 +27,46 @@ class CRUDUser:
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 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) update_data = obj_in.dict(exclude_unset=True)
changes = []
if "password" in update_data: if "password" in update_data:
update_data["password"] = get_password_hash(update_data["password"]) update_data["password"] = get_password_hash(update_data["password"])
changes.append("password changed")
for field, value in update_data.items(): for field, value in update_data.items():
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) setattr(db_obj, field, value)
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]: 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 # For future use if we add user status
return True return True
def change_password(self, db: Session, db_obj: User, new_password: str) -> User: 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)""" """Change a user's password (typically used by admins to reset another user's password)"""
hashed_password = get_password_hash(new_password) hashed_password = get_password_hash(new_password)
db_obj.password = hashed_password db_obj.password = hashed_password
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
+8 -87
View File
@@ -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 fastapi.middleware.cors import CORSMiddleware
from typing import List from typing import List
import json import json
import logging import logging
import asyncio
import redis.asyncio as redis
from app.core.config import settings from app.core.config import settings
from app.api.api import api_router from app.api.api import api_router
@@ -14,15 +12,14 @@ from app.models.journal import JournalEntry
from app.models.local_flight import LocalFlight from app.models.local_flight import LocalFlight
from app.models.departure import Departure from app.models.departure import Departure
from app.models.arrival import Arrival 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 # Set up logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Redis client for pub/sub (cross-worker communication)
redis_client = None
pubsub = None
app = FastAPI( app = FastAPI(
title=settings.project_name, title=settings.project_name,
openapi_url=f"{settings.api_v1_str}/openapi.json", openapi_url=f"{settings.api_v1_str}/openapi.json",
@@ -43,7 +40,6 @@ app.add_middleware(
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
self.active_connections: List[WebSocket] = [] self.active_connections: List[WebSocket] = []
self.redis_listener_task = None
async def connect(self, websocket: WebSocket): async def connect(self, websocket: WebSocket):
await websocket.accept() await websocket.accept()
@@ -58,10 +54,11 @@ class ConnectionManager:
async def send_personal_message(self, message: str, websocket: WebSocket): async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message) await websocket.send_text(message)
async def broadcast_local(self, message_str: str): async def broadcast(self, message: dict):
"""Broadcast to connections on this worker only""" """Broadcast an update to every websocket connected to this process."""
message_str = json.dumps(message)
dead_connections = [] dead_connections = []
for connection in self.active_connections: for connection in list(self.active_connections):
try: try:
await connection.send_text(message_str) await connection.send_text(message_str)
except Exception as e: except Exception as e:
@@ -76,84 +73,8 @@ class ConnectionManager:
if dead_connections: if dead_connections:
logger.info(f"Removed {len(dead_connections)} dead connections") logger.info(f"Removed {len(dead_connections)} dead connections")
async def broadcast(self, message: dict):
"""Broadcast via Redis pub/sub to all workers"""
message_str = json.dumps(message)
print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
try:
if redis_client:
await redis_client.publish('ppr_updates', message_str)
print(f"✓ Message published to Redis")
else:
# Fallback to local broadcast if Redis not available
print("⚠ Redis not available, falling back to local broadcast")
logger.warning("Redis not available, falling back to local broadcast")
await self.broadcast_local(message_str)
except Exception as e:
print(f"✗ Failed to publish to Redis: {e}")
logger.error(f"Failed to publish to Redis: {e}")
# Fallback to local broadcast
await self.broadcast_local(message_str)
async def start_redis_listener(self):
"""Listen for Redis pub/sub messages and broadcast to local connections"""
global redis_client, pubsub
try:
# Connect to Redis
redis_url = settings.redis_url or "redis://redis:6379"
print(f"Connecting to Redis at: {redis_url}")
redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe('ppr_updates')
print("✓ Redis listener started for PPR updates")
logger.info("Redis listener started for PPR updates")
async for message in pubsub.listen():
if message['type'] == 'message':
message_data = message['data']
print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
await self.broadcast_local(message_data)
except Exception as e:
print(f"Redis listener error: {e}")
logger.error(f"Redis listener error: {e}")
await asyncio.sleep(5) # Wait before retry
# Retry connection
if self.redis_listener_task and not self.redis_listener_task.done():
asyncio.create_task(self.start_redis_listener())
manager = ConnectionManager() manager = ConnectionManager()
@app.on_event("startup")
async def startup_event():
"""Start Redis listener when application starts"""
print("=" * 50)
print("STARTUP: Starting application and Redis listener...")
print("=" * 50)
logger.info("Starting application and Redis listener...")
manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up Redis connections on shutdown"""
logger.info("Shutting down application...")
global redis_client, pubsub
if manager.redis_listener_task:
manager.redis_listener_task.cancel()
if pubsub:
await pubsub.unsubscribe('ppr_updates')
await pubsub.close()
if redis_client:
await redis_client.close()
@app.websocket("/ws/tower-updates") @app.websocket("/ws/tower-updates")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket) await manager.connect(websocket)
+17 -3
View File
@@ -1,14 +1,25 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func 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 enum import Enum
from datetime import datetime 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): class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN" BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED" LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
@@ -21,10 +32,13 @@ class Arrival(Base):
callsign = Column(String(16), nullable=True) callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False) pob = Column(Integer, nullable=False)
in_from = Column(String(4), nullable=False, index=True) 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) notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
eta = Column(DateTime, nullable=True, index=True) eta = Column(DateTime, nullable=True, index=True)
landed_dt = Column(DateTime, nullable=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) 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) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+2 -1
View File
@@ -7,6 +7,7 @@ class Circuit(Base):
__tablename__ = "circuits" __tablename__ = "circuits"
id = Column(BigInteger, primary_key=True, autoincrement=True) 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) circuit_timestamp = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
+14 -3
View File
@@ -1,15 +1,21 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func 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 enum import Enum
from datetime import datetime 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): class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT" BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
PENDING = "PENDING"
class Departure(Base): class Departure(Base):
@@ -25,6 +31,11 @@ class Departure(Base):
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure 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) 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) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+63
View File
@@ -0,0 +1,63 @@
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"
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
+3
View File
@@ -7,10 +7,13 @@ from app.db.session import Base
class EntityType(str, PyEnum): class EntityType(str, PyEnum):
"""Entity types that can have journal entries""" """Entity types that can have journal entries"""
PPR = "PPR" PPR = "PPR"
DRONE_REQUEST = "DRONE_REQUEST"
LOCAL_FLIGHT = "LOCAL_FLIGHT" LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL" ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE" DEPARTURE = "DEPARTURE"
OVERFLIGHT = "OVERFLIGHT" OVERFLIGHT = "OVERFLIGHT"
CIRCUIT = "CIRCUIT"
USER = "USER"
class JournalEntry(Base): class JournalEntry(Base):
+16 -1
View File
@@ -4,6 +4,11 @@ from enum import Enum
from app.db.session import Base from app.db.session import Base
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightType(str, Enum): class LocalFlightType(str, Enum):
LOCAL = "LOCAL" LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS" CIRCUITS = "CIRCUITS"
@@ -12,7 +17,13 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum): class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT" BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED" LANDED = "LANDED"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
@@ -24,7 +35,7 @@ class LocalFlight(Base):
registration = Column(String(16), nullable=False, index=True) registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=False) # Aircraft type type = Column(String(32), nullable=False) # Aircraft type
callsign = Column(String(16), nullable=True) 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) flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True) status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes duration = Column(Integer, nullable=True) # Duration in minutes
@@ -32,7 +43,11 @@ class LocalFlight(Base):
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=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 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 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) landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=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()) updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+37
View File
@@ -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'),
)
+15
View File
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
LANDED = "LANDED" LANDED = "LANDED"
DELETED = "DELETED" DELETED = "DELETED"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED"
class UserRole(str, Enum): class UserRole(str, Enum):
@@ -42,6 +43,8 @@ class PPRRecord(Base):
departed_dt = Column(DateTime, nullable=True) departed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True) created_by = Column(String(16), nullable=True, index=True)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), 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) 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()) 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) clean_reg = Column(String(25), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=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())
+21
View File
@@ -6,10 +6,23 @@ from enum import Enum
class ArrivalStatus(str, Enum): class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN" BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED" LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalBase(BaseModel): class ArrivalBase(BaseModel):
registration: str registration: str
type: Optional[str] = None type: Optional[str] = None
@@ -39,6 +52,7 @@ class ArrivalBase(BaseModel):
class ArrivalCreate(ArrivalBase): class ArrivalCreate(ArrivalBase):
eta: Optional[datetime] = None eta: Optional[datetime] = None
submitted_via: Optional[SubmissionSource] = SubmissionSource.ADMIN
class ArrivalUpdate(BaseModel): class ArrivalUpdate(BaseModel):
@@ -47,6 +61,10 @@ class ArrivalUpdate(BaseModel):
callsign: Optional[str] = None callsign: Optional[str] = None
pob: Optional[int] = None pob: Optional[int] = None
in_from: Optional[str] = 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 notes: Optional[str] = None
@@ -61,8 +79,11 @@ class Arrival(ArrivalBase):
created_dt: datetime created_dt: datetime
eta: Optional[datetime] = None eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None landed_dt: Optional[datetime] = None
arrived_dt: Optional[datetime] = None
created_by: Optional[str] = None created_by: Optional[str] = None
updated_at: datetime updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
+2 -1
View File
@@ -4,7 +4,8 @@ from typing import Optional
class CircuitBase(BaseModel): class CircuitBase(BaseModel):
local_flight_id: int local_flight_id: Optional[int] = None
arrival_id: Optional[int] = None
circuit_timestamp: datetime circuit_timestamp: datetime
+40
View File
@@ -0,0 +1,40 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
class ContactEnquiryType(str, Enum):
GENERAL = "general"
AVIATION_BUSINESS = "aviation_business"
PILOT = "pilot"
EVENTS = "events"
COMMUNITY = "community"
class ContactRequestCreate(BaseModel):
name: str = Field(..., max_length=128)
email: EmailStr
phone: Optional[str] = Field(None, max_length=32)
enquiry_type: ContactEnquiryType
subject: str = Field(..., max_length=160)
message: str = Field(..., min_length=1, max_length=4000)
source_page: Optional[str] = Field(None, max_length=256)
@validator("name", "subject", "message")
def validate_required_text(cls, value):
value = value.strip()
if not value:
raise ValueError("Field is required")
return value
@validator("phone", "source_page")
def strip_optional_text(cls, value):
if value is None:
return value
value = value.strip()
return value or None
class ContactRequestReceipt(BaseModel):
status: str = "received"
+22 -1
View File
@@ -6,8 +6,16 @@ from enum import Enum
class DepartureStatus(str, Enum): class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT" BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
PENDING = "PENDING"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureBase(BaseModel): class DepartureBase(BaseModel):
@@ -39,7 +47,7 @@ class DepartureBase(BaseModel):
class DepartureCreate(DepartureBase): class DepartureCreate(DepartureBase):
pass arrival_id: Optional[int] = None
class DepartureUpdate(BaseModel): class DepartureUpdate(BaseModel):
@@ -48,7 +56,11 @@ class DepartureUpdate(BaseModel):
callsign: Optional[str] = None callsign: Optional[str] = None
pob: Optional[int] = None pob: Optional[int] = None
out_to: Optional[str] = None out_to: Optional[str] = None
status: Optional[DepartureStatus] = None
etd: Optional[datetime] = None etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -62,4 +74,13 @@ class Departure(DepartureBase):
status: DepartureStatus status: DepartureStatus
created_dt: datetime created_dt: datetime
etd: Optional[datetime] = None etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_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
+110
View File
@@ -0,0 +1,110 @@
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"
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
class DroneRequestPublicSubmission(DroneRequest):
request_id: str
secure_link: str
+19 -1
View File
@@ -12,16 +12,27 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum): class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT" BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED" LANDED = "LANDED"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightBase(BaseModel): class LocalFlightBase(BaseModel):
registration: str registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None callsign: Optional[str] = None
pob: int pob: Optional[int] = None
flight_type: LocalFlightType flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45 duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure etd: Optional[datetime] = None # Estimated Time of Departure
@@ -61,7 +72,10 @@ class LocalFlightUpdate(BaseModel):
duration: Optional[int] = None duration: Optional[int] = None
status: Optional[LocalFlightStatus] = None status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None circuits: Optional[int] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -76,11 +90,15 @@ class LocalFlightInDBBase(LocalFlightBase):
status: LocalFlightStatus status: LocalFlightStatus
created_dt: datetime created_dt: datetime
etd: Optional[datetime] = None etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None landed_dt: Optional[datetime] = None
circuits: Optional[int] = None circuits: Optional[int] = None
created_by: Optional[str] = None created_by: Optional[str] = None
updated_at: datetime updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
+73
View File
@@ -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
+23
View File
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
LANDED = "LANDED" LANDED = "LANDED"
DELETED = "DELETED" DELETED = "DELETED"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED"
class UserRole(str, Enum): class UserRole(str, Enum):
@@ -87,6 +88,8 @@ class PPRInDBBase(PPRBase):
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
created_by: Optional[str] = None created_by: Optional[str] = None
submitted_dt: datetime submitted_dt: datetime
acknowledged_dt: Optional[datetime] = None
acknowledged_by: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -215,3 +218,23 @@ class Aircraft(AircraftBase):
class Config: class Config:
from_attributes = True 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
+129
View File
@@ -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,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Website Contact Request</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;">Website Contact Request</h1>
<p style="margin: 8px 0 0; font-size: 17px;">{{ enquiry_type | e }}</p>
</td>
</tr>
<tr>
<td style="padding: 28px;">
<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>Submitted</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ submitted_at | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Name</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ name | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Email</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ email | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Phone</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ phone | default("-", true) | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Category</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ enquiry_type | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Subject</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ subject | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Source page</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ source_page | default("-", true) | e }}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Client IP</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ client_ip | e }}</td>
</tr>
</table>
<h2 style="font-size: 19px; margin: 0 0 10px;">Message</h2>
<div style="border: 1px solid #dfe5eb; background: #f8fafc; padding: 14px 16px; white-space: pre-wrap;">{{ message | e }}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -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,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Drone Request Awaiting Review</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background: #34495e; color: #ffffff; padding: 24px;">
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
</td>
</tr>
<tr>
<td style="padding: 24px;">
<p style="font-size: 17px; margin: 0 0 18px;">
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
</p>
<p style="margin: 0 0 22px;">
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Completion</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ completion_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Location</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ location }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Inside FRZ</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ inside_frz }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
</table>
</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>
+8 -2
View File
@@ -174,6 +174,12 @@ else
exit 1 exit 1
fi fi
echo ""
echo "========================================="
echo "Generating QR Code"
echo "========================================="
python3 /app/generate_qr.py
echo "" echo ""
echo "=========================================" echo "========================================="
echo "Starting Application Server" echo "Starting Application Server"
@@ -182,8 +188,8 @@ echo ""
# Start the application with appropriate settings # Start the application with appropriate settings
if [ "${ENVIRONMENT}" = "production" ]; then if [ "${ENVIRONMENT}" = "production" ]; then
echo "Starting in PRODUCTION mode with multiple workers..." 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 --workers ${WORKERS:-4} exec uvicorn app.main:app --host 0.0.0.0 --port 8000
else else
echo "Starting in DEVELOPMENT mode with auto-reload..." echo "Starting in DEVELOPMENT mode with auto-reload..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+38
View File
@@ -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)
+14
View File
@@ -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
+1 -1
View File
@@ -12,8 +12,8 @@ email-validator==2.1.0
pydantic[email]==2.5.0 pydantic[email]==2.5.0
pydantic-settings==2.0.3 pydantic-settings==2.0.3
pytest==7.4.3 pytest==7.4.3
pytest-cov==4.1.0
pytest-asyncio==0.21.1 pytest-asyncio==0.21.1
httpx==0.25.2 httpx==0.25.2
redis==5.0.1
aiosmtplib==3.0.1 aiosmtplib==3.0.1
jinja2==3.1.2 jinja2==3.1.2
+199
View File
@@ -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.
+151
View File
@@ -0,0 +1,151 @@
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("DRONE_REQUEST_TOWER_EMAIL", "tower@swansea-airport.wales")
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
+52
View File
@@ -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]
+80
View File
@@ -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
)
+97
View File
@@ -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,65 @@
def contact_payload(**overrides):
payload = {
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "07123 456789",
"enquiry_type": "aviation_business",
"subject": "Basing a maintenance business at Swansea",
"message": "We would like to explore operating from Swansea Airport.",
"source_page": "/contact/",
}
payload.update(overrides)
return payload
def test_public_contact_request_emails_tower_and_logs(client, monkeypatch, capsys):
sent_emails = []
async def fake_send_email(**kwargs):
sent_emails.append(kwargs)
return True
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
response = client.post(
"/api/v1/contact-requests/public",
json=contact_payload(),
headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"},
)
assert response.status_code == 200
assert response.json() == {"status": "received"}
assert len(sent_emails) == 1
email = sent_emails[0]
assert email["to_email"] == "tower@swansea-airport.wales"
assert email["reply_to"] == "Jane Smith <jane@example.com>"
assert email["subject"] == "Website contact: Basing a maintenance business at Swansea"
assert email["template_name"] == "contact_request.html"
assert email["template_vars"]["name"] == "Jane Smith"
assert email["template_vars"]["enquiry_type"] == "aviation_business"
assert email["template_vars"]["client_ip"] == "203.0.113.10"
log_output = capsys.readouterr().out
assert "Public contact request received" in log_output
assert "aviation_business" in log_output
assert "jane@example.com" in log_output
def test_public_contact_request_validation(client, monkeypatch):
async def fake_send_email(**kwargs):
return True
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
invalid_category = client.post(
"/api/v1/contact-requests/public",
json=contact_payload(enquiry_type="sales"),
)
blank_required = client.post(
"/api/v1/contact-requests/public",
json=contact_payload(name=" ", subject="", message=" "),
)
assert invalid_category.status_code == 422
assert blank_required.status_code == 422
+158
View File
@@ -0,0 +1,158 @@
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) == 2
assert sent_emails[1]["to_email"] == "tower@swansea-airport.wales"
assert sent_emails[1]["template_name"] == "drone_request_tower_notification.html"
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) == 3
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) == 4
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
+318
View File
@@ -0,0 +1,318 @@
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 departed_response.json()["departed_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_local_flight_takeoff_to_local_sets_departed_dt(auth_client):
create_response = auth_client.post(
"/api/v1/local-flights/",
json={
"registration": "g-air",
"type": "PA28",
"pob": 2,
"flight_type": "LOCAL",
"duration": 30,
"etd": "2026-06-20T09:00:00",
},
)
assert create_response.status_code == 200
takeoff_response = auth_client.patch(
f"/api/v1/local-flights/{create_response.json()['id']}/status",
json={"status": "LOCAL", "timestamp": "2026-06-20T09:05:00"},
)
assert takeoff_response.status_code == 200
assert takeoff_response.json()["status"] == "LOCAL"
assert takeoff_response.json()["takeoff_dt"] == "2026-06-20T09:05:00"
assert takeoff_response.json()["departed_dt"] == "2026-06-20T09:05:00"
landing_response = auth_client.patch(
f"/api/v1/local-flights/{create_response.json()['id']}/status",
json={"status": "LANDED", "timestamp": "2026-06-20T09:35:00"},
)
assert landing_response.status_code == 200
assert landing_response.json()["status"] == "LANDED"
assert landing_response.json()["landed_dt"] == "2026-06-20T09:35:00"
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
+76
View File
@@ -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"]
-42
View File
@@ -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)
+227
View File
@@ -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
+187
View File
@@ -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
+241
View File
@@ -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() == []
+138
View File
@@ -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
+2
View File
@@ -41,6 +41,8 @@ CREATE TABLE submitted (
departed_dt DATETIME DEFAULT NULL, departed_dt DATETIME DEFAULT NULL,
created_by VARCHAR(16) DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL,
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
acknowledged_dt DATETIME DEFAULT NULL,
acknowledged_by VARCHAR(50) DEFAULT NULL,
public_token VARCHAR(128) DEFAULT NULL UNIQUE, public_token VARCHAR(128) DEFAULT NULL UNIQUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+80
View File
@@ -0,0 +1,80 @@
services:
db:
container_name: ppr_e2e_db
build:
context: .
dockerfile: tests/e2e/mysql.Dockerfile
image: pprdev-e2e-db
environment:
MYSQL_ROOT_PASSWORD: e2e_root_password
MYSQL_DATABASE: ppr_e2e
MYSQL_USER: ppr_e2e
MYSQL_PASSWORD: ppr_e2e_password
volumes:
- ppr_e2e_mysql_data:/var/lib/mysql
api:
container_name: ppr_e2e_api
ports:
- "${E2E_API_PORT_EXTERNAL:-18002}:8000"
environment:
DB_HOST: db
DB_PORT: 3306
DB_NAME: ppr_e2e
DB_USER: ppr_e2e
DB_PASSWORD: ppr_e2e_password
BASE_URL: http://web
MAIL_HOST: 127.0.0.1
MAIL_PORT: 1
MAIL_USERNAME: e2e
MAIL_PASSWORD: e2e
MAIL_FROM: e2e@example.com
MAIL_FROM_NAME: PPR E2E
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
web:
container_name: ppr_e2e_web
ports:
- "${E2E_WEB_PORT_EXTERNAL:-18055}:80"
environment:
BASE_URL: ""
command: >
sh -c "if [ -z \"$${BASE_URL}\" ]; then API_BASE='/api/v1'; else API_BASE=\"$${BASE_URL}/api/v1\"; fi;
printf 'window.PPR_CONFIG = { apiBase: \"%s\" };' \"$${API_BASE}\" > /usr/share/nginx/html/config.js;
nginx -g 'daemon off;'"
e2e:
build:
context: .
dockerfile: tests/e2e/Dockerfile
working_dir: /workspace
volumes:
- .:/workspace
environment:
E2E_BASE_URL: http://web
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-}
E2E_HEALTH_URL: http://api:8000/health
E2E_ARTIFACT_UID: ${E2E_ARTIFACT_UID:-1000}
E2E_ARTIFACT_GID: ${E2E_ARTIFACT_GID:-1000}
PYTHONDONTWRITEBYTECODE: "1"
depends_on:
- web
networks:
- public_network
command: >
bash -lc "mkdir -p test-results &&
python tests/e2e/wait_for_web.py &&
pytest tests/e2e
--browser chromium
--tracing=retain-on-failure
--screenshot=only-on-failure
--junitxml=test-results/e2e-junit.xml
--html=test-results/e2e-report.html
--self-contained-html;
status=$$?;
chown -R \"$${E2E_ARTIFACT_UID}:$${E2E_ARTIFACT_GID}\" test-results || true;
exit $$status"
volumes:
ppr_e2e_mysql_data:
+4 -15
View File
@@ -24,17 +24,18 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD} MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM} MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME} MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL} BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TAG: ${TAG} TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING}
ENVIRONMENT: production ENVIRONMENT: production
WORKERS: "4"
ports: ports:
- "${API_PORT_EXTERNAL}:8000" - "${API_PORT_EXTERNAL}:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding - ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks: networks:
- app_network - app_network
extra_hosts: extra_hosts:
@@ -48,24 +49,12 @@ services:
cpus: '1' cpus: '1'
memory: 1G memory: 1G
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s 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 # Nginx web server for public frontend
web: web:
image: nginx:alpine image: nginx:alpine
+2 -9
View File
@@ -36,8 +36,8 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD} MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM} MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME} MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL} BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TOWER_NAME: ${TOWER_NAME} TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: ${ENVIRONMENT} ENVIRONMENT: ${ENVIRONMENT}
@@ -48,6 +48,7 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding - ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks: networks:
- private_network - private_network
- public_network - public_network
@@ -71,14 +72,6 @@ services:
networks: networks:
- public_network - 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 for database management
phpmyadmin: phpmyadmin:
image: phpmyadmin/phpmyadmin image: phpmyadmin/phpmyadmin
+2
View File
@@ -39,11 +39,13 @@ http {
# Serve HTML files without .html extension (e.g., /admin -> admin.html) # Serve HTML files without .html extension (e.g., /admin -> admin.html)
location ~ ^/([a-zA-Z0-9_-]+)$ { location ~ ^/([a-zA-Z0-9_-]+)$ {
ssi on;
try_files /$1.html =404; try_files /$1.html =404;
} }
# Serve static files # Serve static files
location / { location / {
ssi on;
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
# Apply X-Frame-Options to other files # Apply X-Frame-Options to other files
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
+6
View File
@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
WORKDIR /workspace
COPY tests/e2e/requirements.txt /tmp/e2e-requirements.txt
RUN pip install --no-cache-dir -r /tmp/e2e-requirements.txt
+90
View File
@@ -0,0 +1,90 @@
# End-to-End Tests
The e2e suite uses pytest plus Playwright. The preferred path is the containerized runner, which joins the same Docker Compose network as the web app and opens `http://web`.
## Containerized Run
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
The e2e override sets `E2E_BASE_URL=http://web`, makes the web container generate relative API config for browser-side requests, and forces the API container to use an e2e-only Compose database.
The e2e database is a real MySQL server, but it is isolated from the normal dev database:
- DB container: `ppr_e2e_db`
- API container: `ppr_e2e_api`
- Web container: `ppr_e2e_web`
- Database name: `ppr_e2e`
- Volume: `pprdev_ppr_e2e_mysql_data`
The e2e DB image is plain `mysql:8.0`, so the API should see a fresh empty database and create schema through Alembic migrations instead of stamping an older `db-init` schema.
Authenticated tests are skipped unless credentials are supplied:
```bash
E2E_ADMIN_USERNAME=admin \
E2E_ADMIN_PASSWORD=admin123 \
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
## Rebuild The Test Image
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml build e2e
```
## Test Evidence
Containerized runs write evidence to `test-results/`:
- `test-results/e2e-report.html` for a human-readable report
- `test-results/e2e-junit.xml` for CI systems
- Playwright traces and screenshots on failures
If the report files are not owned by your host user, pass your UID/GID:
```bash
E2E_ARTIFACT_UID=$(id -u) \
E2E_ARTIFACT_GID=$(id -g) \
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
## Host Run
Running on the host is still supported for quick debugging if Python and Playwright are installed locally.
First-time host setup:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements.txt
pip install -r tests/e2e/requirements.txt
python -m playwright install --with-deps chromium
```
Run against a host-exposed web port:
```bash
E2E_BASE_URL=http://localhost:8082 pytest tests/e2e
```
Run authenticated host tests:
```bash
E2E_BASE_URL=http://localhost:8082 \
E2E_ADMIN_USERNAME=admin \
E2E_ADMIN_PASSWORD=admin123 \
pytest tests/e2e
```
## Adding Tests
Put browser specs in `tests/e2e/test_*.py`. Start with user-visible behavior and stable selectors:
- Navigate through the same URLs users open.
- Prefer roles and labels, such as `get_by_role()` and `get_by_label()`.
- Use API setup only when a test needs specific records to exist.
- Keep specs independent so they can run in any order.
+27
View File
@@ -0,0 +1,27 @@
import os
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
ADMIN_USERNAME = os.getenv("E2E_ADMIN_USERNAME")
ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def login_as_admin(page):
if page.locator("#login-username").is_visible():
page.locator("#login-username").fill(ADMIN_USERNAME)
page.locator("#login-password").fill(ADMIN_PASSWORD)
page.locator("#login-btn").click()
expect(page.locator("#current-user")).to_have_text(ADMIN_USERNAME)
if page.get_by_role("heading", name="Login").count() > 0:
expect(page.get_by_role("heading", name="Login")).to_be_hidden()
def skip_without_admin_credentials(pytest):
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
pytest.skip("Set E2E_ADMIN_USERNAME and E2E_ADMIN_PASSWORD to run authenticated e2e tests")
+1
View File
@@ -0,0 +1 @@
FROM mysql:8.0
+3
View File
@@ -0,0 +1,3 @@
playwright==1.45.0
pytest-playwright>=0.5,<0.6
pytest-html>=4,<5
+73
View File
@@ -0,0 +1,73 @@
import re
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def open_admin_dropdown(page):
page.locator("#adminDropdownBtn").click()
admin_menu = page.locator("#adminDropdownMenu")
expect(admin_menu).to_have_class(re.compile("active"))
return admin_menu
def open_admin_page(page):
skip_without_admin_credentials(pytest)
page.goto(app_url("/admin"))
expect(page).to_have_title(re.compile("PPR Admin Interface"))
expect(page.get_by_role("heading", name=re.compile("Swansea Tower"))).to_be_visible()
login_as_admin(page)
def test_actions_and_admin_dropdowns_toggle_exclusively(page):
open_admin_page(page)
actions_menu = page.locator("#actionsDropdownMenu")
admin_menu = page.locator("#adminDropdownMenu")
expect(actions_menu).not_to_have_class(re.compile("active"))
expect(admin_menu).not_to_have_class(re.compile("active"))
page.locator("#actionsDropdownBtn").click()
expect(actions_menu).to_have_class(re.compile("active"))
expect(admin_menu).not_to_have_class(re.compile("active"))
expect(actions_menu.get_by_role("link", name=re.compile("New PPR"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Book Out"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Book In"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Overflight"))).to_be_visible()
admin_menu = open_admin_dropdown(page)
expect(actions_menu).not_to_have_class(re.compile("active"))
expect(admin_menu.get_by_role("link", name=re.compile("Admin View"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("ATC View"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Reports"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Drone Requests"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Journal Log"))).to_be_visible()
page.locator(".container").click()
expect(admin_menu).not_to_have_class(re.compile("active"))
def test_admin_menu_links_navigate_to_expected_pages(page):
open_admin_page(page)
menu_expectations = [
("Admin View", re.compile(r"/admin$"), re.compile("PPR Admin Interface"), "title"),
("ATC View", re.compile(r"/atc$"), re.compile("ATC Management Interface"), "title"),
("Reports", re.compile(r"/reports$"), re.compile("PPR Reports"), "heading"),
("Drone Requests", re.compile(r"/drone-requests$"), re.compile("Drone Flight Requests"), "heading"),
("Journal Log", re.compile(r"/journal$"), re.compile("Journal Log"), "heading"),
]
for label, url_pattern, expected_text, assertion_type in menu_expectations:
page.goto(app_url("/admin"))
login_as_admin(page)
admin_menu = open_admin_dropdown(page)
admin_menu.get_by_role("link", name=re.compile(label)).click()
expect(page).to_have_url(url_pattern)
if assertion_type == "title":
expect(page).to_have_title(expected_text)
else:
expect(page.get_by_role("heading", name=expected_text)).to_be_visible()
+64
View File
@@ -0,0 +1,64 @@
import re
import json
from datetime import datetime
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def drone_payload(operator_name):
return {
"operator_name": operator_name,
"operator_id": "E2E-OP",
"flyer_name": "E2E Remote Pilot",
"flyer_id": "E2E-FLYER",
"email": "drone-e2e@example.com",
"phone": "0123456789",
"flight_date": "2026-06-21",
"estimated_takeoff_time": "10:00",
"estimated_completion_time": "10:30",
"estimated_takeoff_at": "2026-06-21T10:00:00",
"estimated_completion_at": "2026-06-21T10:30:00",
"maximum_elevation_ft_amsl": 200,
"location_description": "E2E north apron survey",
"location_latitude": 51.623389,
"location_longitude": -4.069231,
"location_inside_frz": "yes",
"notes": "Created by Playwright e2e",
}
def test_drone_requests_requires_login_and_loads_queue(page):
skip_without_admin_credentials(pytest)
page.goto(app_url("/drone-requests"))
expect(page).to_have_title(re.compile("Drone Flight Requests"))
expect(page.get_by_role("heading", name="Drone Flight Requests")).to_be_visible()
expect(page.get_by_role("heading", name="Login")).to_be_visible()
login_as_admin(page)
expect(page.locator("#request-list-body")).not_to_contain_text("Loading requests...")
expect(page.locator("#request-count")).to_have_text(re.compile(r"\d+"))
def test_public_drone_request_appears_in_tower_queue(page):
skip_without_admin_credentials(pytest)
operator_name = f"E2E Rotor Ops {datetime.utcnow().strftime('%H%M%S%f')}"
create_response = page.request.post(
app_url("/api/v1/drone-requests/public"),
data=json.dumps(drone_payload(operator_name)),
headers={"Content-Type": "application/json"},
)
assert create_response.ok, create_response.text()
created = create_response.json()
page.goto(app_url("/drone-requests"))
login_as_admin(page)
expect(page.locator("#request-list-body")).to_contain_text(created["reference_number"])
expect(page.locator("#request-list-body")).to_contain_text(operator_name)
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def local_flight_row(page, registration):
return page.locator("#local-flights-table-body tr").filter(has_text=registration)
def confirm_timestamp(page, button_text):
expect(page.locator("#timestampModal")).to_be_visible()
expect(page.locator("#timestamp-submit-btn")).to_contain_text(button_text)
page.locator("#timestamp-submit-btn").click()
expect(page.locator("#timestampModal")).to_be_hidden()
def test_operator_books_out_local_flight_records_touch_and_go_and_lands(page):
skip_without_admin_credentials(pytest)
registration = f"GE2E{datetime.utcnow().strftime('%H%M%S')}"
page.goto(app_url("/admin"))
login_as_admin(page)
page.locator("#actionsDropdownBtn").click()
page.locator("#actionsDropdownMenu").get_by_role("link", name="🛫 Book Out (L)").click()
expect(page.locator("#localFlightModal")).to_be_visible()
page.locator("#local_registration").fill(registration)
page.locator("#local_type").fill("PA28")
page.locator("#local_pob").fill("1")
page.locator("#local_flight_type").select_option("LOCAL")
page.locator("#local_duration").fill("45")
page.locator("#local_notes").fill("E2E local flight lifecycle")
page.locator("#local-flight-form").get_by_role("button", name="🛫 Book Out").click()
expect(page.locator("#localFlightModal")).to_be_hidden()
row = local_flight_row(page, registration)
expect(row).to_be_visible()
expect(row).to_contain_text("GROUND")
row.get_by_role("button", name="TAKE OFF").click()
confirm_timestamp(page, "Confirm Takeoff")
expect(row).to_contain_text("LOCAL")
row.get_by_role("button", name="T&G").click()
expect(page.locator("#circuitModal")).to_be_visible()
page.locator("#circuit-form").get_by_role("button", name="Record Circuit").click()
expect(page.locator("#circuitModal")).to_be_hidden()
expect(row.locator("td").nth(7)).to_have_text("1")
row.get_by_role("button", name="LAND").click()
confirm_timestamp(page, "Confirm Landing")
expect(local_flight_row(page, registration)).to_have_count(0)
+26
View File
@@ -0,0 +1,26 @@
import os
import re
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def test_flight_information_display_loads(page):
page.goto(app_url("/"))
expect(page).to_have_title(re.compile("Swansea Airport - Arrivals & Departures"))
expect(page.get_by_role("heading", name="Flight Information")).to_be_visible()
def test_public_ppr_form_loads(page):
page.goto(app_url("/ppr.html"))
expect(page).to_have_title(re.compile("Swansea PPR"))
expect(page.get_by_role("heading", name=re.compile("PPR Request"))).to_be_visible()
expect(page.get_by_role("button", name="Submit PPR Request")).to_be_visible()
+38
View File
@@ -0,0 +1,38 @@
import os
from datetime import datetime
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def test_public_ppr_form_submits_successfully(page):
unique_registration = f"GE{datetime.utcnow().strftime('%H%M')}"
page.goto(app_url("/ppr.html"))
page.locator("#ac_reg").fill(unique_registration)
page.locator("#ac_type").fill("PA28")
page.locator("#ac_call").fill(unique_registration)
page.locator("#captain").fill("E2E Test Pilot")
page.locator("#in_from").fill("EGLL")
page.locator("#eta-date").fill("2026-06-21")
page.locator("#eta-time").select_option("10:00")
page.locator("#pob_in").fill("2")
page.locator("#fuel").select_option("100LL")
page.locator("#out_to").fill("EGFF")
page.locator("#etd-date").fill("2026-06-21")
page.locator("#etd-time").select_option("12:00")
page.locator("#pob_out").fill("2")
page.locator("#phone").fill("0123456789")
page.locator("#notes").fill("Submitted by Playwright e2e")
page.get_by_role("button", name="Submit PPR Request").click()
expect(page.locator("#success-message")).to_be_visible()
expect(page.locator("#success-message")).to_contain_text("PPR Request Submitted")
+24
View File
@@ -0,0 +1,24 @@
import os
import time
import urllib.request
base_url = os.getenv("E2E_BASE_URL", "http://web").rstrip("/")
health_url = os.getenv("E2E_HEALTH_URL", f"{base_url}/")
deadline = time.time() + int(os.getenv("E2E_WEB_TIMEOUT_SECONDS", "120"))
last_error = None
while time.time() < deadline:
try:
with urllib.request.urlopen(health_url, timeout=5) as response:
if response.status < 500:
break
except Exception as exc:
last_error = exc
time.sleep(2)
else:
raise SystemExit(f"Timed out waiting for {health_url}: {last_error}")
with urllib.request.urlopen(f"{base_url}/", timeout=5) as response:
if response.status >= 500:
raise SystemExit(f"Web returned HTTP {response.status} at {base_url}/")
+24
View File
@@ -160,6 +160,15 @@ body {
background-color: #e67e22; background-color: #e67e22;
} }
.btn-ack {
background-color: #8e44ad;
color: white;
}
.btn-ack:hover {
background-color: #71368a;
}
.btn-info { .btn-info {
background-color: #3498db; background-color: #3498db;
color: white; color: white;
@@ -330,6 +339,21 @@ tbody tr:hover {
background-color: #f8f9fa; 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 { .status {
display: inline-block; display: inline-block;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
+368 -3640
View File
File diff suppressed because it is too large Load Diff
+1887
View File
File diff suppressed because it is too large Load Diff
+1287
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More