Compare commits

..

13 Commits

Author SHA1 Message Date
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
40 changed files with 8583 additions and 149 deletions
+2
View File
@@ -1,3 +1,5 @@
web/assets/booking-qr.png
# Python
__pycache__/
*.py[cod]
+2 -1
View File
@@ -3,11 +3,12 @@ FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
# Install system dependencies including qrencode
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
qrencode \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
@@ -0,0 +1,82 @@
"""Add public booking support with submitted_via and pilot_email columns
Revision ID: 003_public_booking
Revises: 002_local_flights
Create Date: 2026-02-20 12:00:00.000000
This migration adds support for public flight booking by adding:
- submitted_via enum field to track ADMIN vs PUBLIC submissions
- pilot_email field to store contact info for public submissions
- Indexes on submitted_via for filtering queries
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_public_booking'
down_revision = '002_local_flights'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Add public booking support columns to local_flights, departures, and arrivals tables.
"""
# Create the SubmissionSource enum type
submission_source_enum = sa.Enum('ADMIN', 'PUBLIC', name='submissionsource')
# Add submitted_via and pilot_email to local_flights table
op.add_column('local_flights', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('local_flights', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on local_flights
op.create_index('idx_lf_submitted_via', 'local_flights', ['submitted_via'])
op.create_index('idx_lf_pilot_email', 'local_flights', ['pilot_email'])
# Add submitted_via and pilot_email to departures table
op.add_column('departures', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('departures', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on departures
op.create_index('idx_dep_submitted_via', 'departures', ['submitted_via'])
op.create_index('idx_dep_pilot_email', 'departures', ['pilot_email'])
# Add submitted_via and pilot_email to arrivals table
op.add_column('arrivals', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('arrivals', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on arrivals
op.create_index('idx_arr_submitted_via', 'arrivals', ['submitted_via'])
op.create_index('idx_arr_pilot_email', 'arrivals', ['pilot_email'])
def downgrade() -> None:
"""
Remove the submitted_via and pilot_email columns from local_flights, departures, and arrivals tables.
"""
# Drop indexes first
op.drop_index('idx_lf_submitted_via', table_name='local_flights')
op.drop_index('idx_lf_pilot_email', table_name='local_flights')
op.drop_index('idx_dep_submitted_via', table_name='departures')
op.drop_index('idx_dep_pilot_email', table_name='departures')
op.drop_index('idx_arr_submitted_via', table_name='arrivals')
op.drop_index('idx_arr_pilot_email', table_name='arrivals')
# Drop columns from local_flights
op.drop_column('local_flights', 'pilot_email')
op.drop_column('local_flights', 'submitted_via')
# Drop columns from departures
op.drop_column('departures', 'pilot_email')
op.drop_column('departures', 'submitted_via')
# Drop columns from arrivals
op.drop_column('arrivals', 'pilot_email')
op.drop_column('arrivals', 'submitted_via')
# Drop the enum type
op.execute('DROP TYPE IF EXISTS submissionsource')
@@ -0,0 +1,50 @@
"""Add user_aircraft table for user-defined aircraft types
Revision ID: 004_user_aircraft
Revises: 003_public_booking
Create Date: 2026-03-23 12:00:00.000000
This migration adds a user_aircraft table to store aircraft types
that are manually entered by users when not found in the main aircraft database.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004_user_aircraft'
down_revision = '003_public_booking'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Create user_aircraft table for storing user-defined aircraft types.
"""
op.create_table('user_aircraft',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('registration', sa.String(length=25), nullable=False),
sa.Column('type_code', sa.String(length=30), nullable=False),
sa.Column('clean_reg', sa.String(length=25), nullable=False),
sa.Column('created_by', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('registration')
)
# Create indexes
op.create_index('idx_user_aircraft_registration', 'user_aircraft', ['registration'])
op.create_index('idx_user_aircraft_clean_reg', 'user_aircraft', ['clean_reg'])
op.create_index('idx_user_aircraft_created_by', 'user_aircraft', ['created_by'])
def downgrade() -> None:
"""
Drop user_aircraft table.
"""
op.drop_index('idx_user_aircraft_created_by', table_name='user_aircraft')
op.drop_index('idx_user_aircraft_clean_reg', table_name='user_aircraft')
op.drop_index('idx_user_aircraft_registration', table_name='user_aircraft')
op.drop_table('user_aircraft')
@@ -0,0 +1,79 @@
"""Add granular flight states and timestamps
Revision ID: 8adefaee847c
Revises: 004_user_aircraft
Create Date: 2026-03-24 09:09:00.944815
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005_flight_states'
down_revision = '004_user_aircraft'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add GROUND and LOCAL to local_flights status enum
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
# Add timestamp columns to local_flights
op.add_column('local_flights', sa.Column('contact_dt', sa.DateTime(), nullable=True))
op.add_column('local_flights', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
# Add GROUND and ARRIVED to arrivals status enum
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','INBOUND','LANDED','GROUND','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','ARRIVED','CANCELLED')")
# Add timestamp column to arrivals
op.add_column('arrivals', sa.Column('arrived_dt', sa.DateTime(), nullable=True))
# Add GROUND and LOCAL to departures status enum
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
# Add timestamp columns to departures
op.add_column('departures', sa.Column('contact_dt', sa.DateTime(), nullable=True))
op.add_column('departures', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
# Add arrival_id column to circuits table to support circuit logging for arrivals
op.add_column('circuits', sa.Column('arrival_id', sa.BigInteger(), nullable=True))
op.create_foreign_key('fk_circuits_arrival_id', 'circuits', 'arrivals', ['arrival_id'], ['id'], ondelete='CASCADE')
op.create_index('idx_circuit_arrival_id', 'circuits', ['arrival_id'])
def downgrade() -> None:
# Remove arrival_id column from circuits table
op.drop_constraint('fk_circuits_arrival_id', 'circuits', type_='foreignkey')
op.drop_index('idx_circuit_arrival_id', table_name='circuits')
op.drop_column('circuits', 'arrival_id')
# Update departures with new status values to valid old values before modifying enum
op.execute("UPDATE departures SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL')")
# Remove timestamp columns from departures
op.drop_column('departures', 'takeoff_dt')
op.drop_column('departures', 'contact_dt')
# Remove GROUND and LOCAL from departures status enum
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','CANCELLED')")
# Update arrivals with new status values to valid old values before modifying enum
op.execute("UPDATE arrivals SET status = 'LANDED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT', 'ARRIVED')")
# Remove timestamp column from arrivals
op.drop_column('arrivals', 'arrived_dt')
# Remove GROUND and ARRIVED from arrivals status enum
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','LANDED','CANCELLED')")
# Update local_flights with new status values to valid old values before modifying enum
op.execute("UPDATE local_flights SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT')")
# Remove timestamp columns from local_flights
op.drop_column('local_flights', 'takeoff_dt')
op.drop_column('local_flights', 'contact_dt')
# Remove GROUND and LOCAL from local_flights status enum
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','LANDED','CANCELLED')")
+2 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book
api_router = APIRouter()
@@ -12,5 +12,6 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(public.router, prefix="/public", tags=["public"])
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
+147 -4
View File
@@ -1,9 +1,10 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.deps import get_db, get_current_active_user
from app.models.ppr import Aircraft
from app.schemas.ppr import Aircraft as AircraftSchema
from app.models.ppr import Aircraft, UserAircraft
from app.schemas.ppr import Aircraft as AircraftSchema, UserAircraftCreate, UserAircraft as UserAircraftSchema
from app.models.ppr import User
router = APIRouter()
@@ -18,6 +19,7 @@ async def lookup_aircraft_by_registration(
"""
Lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching.
Checks user_aircraft table first, then aircraft table.
"""
# Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
@@ -25,7 +27,29 @@ async def lookup_aircraft_by_registration(
if len(clean_input) < 4:
return []
# Query aircraft table using clean_reg column
# First check user_aircraft table
user_aircraft = db.query(UserAircraft).filter(
UserAircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
if user_aircraft:
# Convert UserAircraft to Aircraft-like objects
result = []
for ua in user_aircraft:
# Create a mock Aircraft object with the user data
result.append({
'id': ua.id,
'registration': ua.registration,
'type_code': ua.type_code,
'clean_reg': ua.clean_reg,
'icao24': None,
'manufacturer_icao': None,
'manufacturer_name': None,
'model': None
})
return result
# If no user aircraft found, check main aircraft table
aircraft_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
@@ -42,6 +66,7 @@ async def public_lookup_aircraft_by_registration(
Public lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching.
No authentication required.
Checks user_aircraft table first, then aircraft table.
"""
# Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
@@ -49,7 +74,28 @@ async def public_lookup_aircraft_by_registration(
if len(clean_input) < 4:
return []
# Query aircraft table using clean_reg column
# First check user_aircraft table
user_aircraft = db.query(UserAircraft).filter(
UserAircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
if user_aircraft:
# Convert UserAircraft to Aircraft-like objects
result = []
for ua in user_aircraft:
result.append({
'id': ua.id,
'registration': ua.registration,
'type_code': ua.type_code,
'clean_reg': ua.clean_reg,
'icao24': None,
'manufacturer_icao': None,
'manufacturer_name': None,
'model': None
})
return result
# If no user aircraft found, check main aircraft table
aircraft_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
@@ -82,3 +128,100 @@ async def search_aircraft(
).limit(limit).all()
return aircraft_list
@router.post("/user-aircraft", response_model=dict)
async def save_user_aircraft(
aircraft: UserAircraftCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Save a user-defined aircraft type for a registration.
"""
# Clean the registration
clean_reg = ''.join(c for c in aircraft.registration if c.isalnum()).upper()
# Check if already exists
existing = db.query(UserAircraft).filter(
UserAircraft.registration == aircraft.registration.upper()
).first()
if existing:
raise HTTPException(status_code=400, detail="Aircraft registration already exists in user database")
# Create new user aircraft
user_aircraft = UserAircraft(
registration=aircraft.registration.upper(),
type_code=aircraft.type_code.upper(),
clean_reg=clean_reg,
created_by=current_user.username
)
db.add(user_aircraft)
db.commit()
db.refresh(user_aircraft)
return {"message": "Aircraft saved successfully", "id": user_aircraft.id}
@router.get("/user-aircraft", response_model=List[UserAircraftSchema])
async def get_user_aircraft(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get all user-defined aircraft types.
"""
user_aircraft = db.query(UserAircraft).order_by(UserAircraft.created_at.desc()).all()
return user_aircraft
@router.put("/user-aircraft/{registration}", response_model=dict)
async def update_user_aircraft(
registration: str,
aircraft: UserAircraftCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update a user-defined aircraft type.
"""
# Find the existing user aircraft
existing = db.query(UserAircraft).filter(
UserAircraft.registration == registration.upper()
).first()
if not existing:
raise HTTPException(status_code=404, detail="User aircraft not found")
# Update the type
existing.type_code = aircraft.type_code.upper()
existing.updated_at = func.current_timestamp()
db.commit()
return {"message": "Aircraft updated successfully"}
@router.delete("/user-aircraft/{registration}", response_model=dict)
async def delete_user_aircraft(
registration: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a user-defined aircraft type.
"""
# Find the existing user aircraft
existing = db.query(UserAircraft).filter(
UserAircraft.registration == registration.upper()
).first()
if not existing:
raise HTTPException(status_code=404, detail="User aircraft not found")
db.delete(existing)
db.commit()
return {"message": "Aircraft deleted successfully"}
+2 -2
View File
@@ -38,12 +38,12 @@ async def create_arrival(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new arrival record"""
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username)
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username, submitted_via=arrival_in.submitted_via)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in",
"type": "arrival_inbound",
"data": {
"id": arrival.id,
"registration": arrival.registration,
+24 -1
View File
@@ -33,6 +33,17 @@ async def get_circuits_by_flight(
return circuits
@router.get("/arrival/{arrival_id}", response_model=List[Circuit])
async def get_circuits_by_arrival(
arrival_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get all circuits for a specific arrival"""
circuits = crud_circuit.get_by_arrival(db, arrival_id=arrival_id)
return circuits
@router.post("/", response_model=Circuit)
async def create_circuit(
request: Request,
@@ -40,7 +51,19 @@ async def create_circuit(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Record a new circuit (touch and go) for a local flight"""
"""Record a new circuit (touch and go) for a local flight or arrival"""
# Validate that exactly one of local_flight_id or arrival_id is provided
if not circuit_in.local_flight_id and not circuit_in.arrival_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either local_flight_id or arrival_id must be provided"
)
if circuit_in.local_flight_id and circuit_in.arrival_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot provide both local_flight_id and arrival_id"
)
circuit = crud_circuit.create(db, obj_in=circuit_in)
# Send real-time update via WebSocket
+1 -1
View File
@@ -38,7 +38,7 @@ async def create_departure(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new departure record"""
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username)
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username, submitted_via="ADMIN")
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
+1 -1
View File
@@ -39,7 +39,7 @@ async def create_local_flight(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new local flight record (book out)"""
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username)
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username, submitted_via="ADMIN")
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
+18 -10
View File
@@ -97,11 +97,11 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
# Add booked-in arrivals
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
for arrival in booked_in_arrivals:
# Only include BOOKED_IN and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
# Only include BOOKED_IN, INBOUND and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND, ArrivalStatus.LANDED):
continue
# For BOOKED_IN, only include those created today
if arrival.status == ArrivalStatus.BOOKED_IN:
# For BOOKED_IN and INBOUND, only include those created today
if arrival.status in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND):
if not (today_start <= arrival.created_dt < today_end):
continue
# For LANDED, only include those landed today
@@ -173,10 +173,10 @@ async def get_public_departures(db: Session = Depends(get_db)):
'isDeparture': False
})
# Add departures to other airports with BOOKED_OUT status
# Add departures to other airports with BOOKED_OUT and GROUND status
departures_to_airports = crud_departure.get_multi(
db,
status=DepartureStatus.BOOKED_OUT,
status=None, # Get all statuses
limit=1000
)
@@ -187,17 +187,25 @@ async def get_public_departures(db: Session = Depends(get_db)):
# Convert departures to match the format for display
for dep in departures_to_airports:
# Only include departures booked out today
if not (today_start <= dep.created_dt < today_end):
# Only include departures booked out today and not yet departed
if not (today_start <= dep.created_dt < today_end) or dep.status == DepartureStatus.DEPARTED:
continue
# Map status for display
display_status = 'BOOKED_OUT'
if dep.status == DepartureStatus.GROUND:
display_status = 'CONTACT'
elif dep.status == DepartureStatus.LOCAL:
display_status = 'DEPARTED'
departures_list.append({
'ac_call': dep.callsign or dep.registration,
'ac_reg': dep.registration,
'ac_type': dep.type,
'out_to': dep.out_to,
'etd': dep.etd or dep.created_dt,
'departed_dt': None,
'status': 'BOOKED_OUT',
'departed_dt': dep.departed_dt,
'status': display_status,
'isLocalFlight': False,
'isDeparture': True
})
+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")
# 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)
# 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")
# 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")
# 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
View File
@@ -33,6 +33,9 @@ class Settings(BaseSettings):
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Public booking settings
allow_public_booking: bool = False # Enable/disable public flight booking
# Redis settings (for future use)
redis_url: Optional[str] = None
+31 -7
View File
@@ -24,7 +24,17 @@ class CRUDArrival:
query = db.query(Arrival)
if status:
query = query.filter(Arrival.status == status)
if status == ArrivalStatus.CIRCUIT:
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
circuit_statuses = [
ArrivalStatus.CIRCUIT,
ArrivalStatus.CIRCUIT_DOWNWIND,
ArrivalStatus.CIRCUIT_BASE,
ArrivalStatus.CIRCUIT_FINAL
]
query = query.filter(Arrival.status.in_(circuit_statuses))
else:
query = query.filter(Arrival.status == status)
if date_from:
query = query.filter(func.date(Arrival.created_dt) >= date_from)
@@ -35,23 +45,33 @@ class CRUDArrival:
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[Arrival]:
"""Get today's arrivals (booked in or landed)"""
"""Get today's arrivals (booked in, inbound or landed)"""
today = date.today()
return db.query(Arrival).filter(
and_(
func.date(Arrival.created_dt) == today,
or_(
Arrival.status == ArrivalStatus.BOOKED_IN,
Arrival.status == ArrivalStatus.INBOUND,
Arrival.status == ArrivalStatus.LANDED
)
)
).order_by(Arrival.created_dt).all()
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN") -> Arrival:
from app.models.arrival import SubmissionSource
# Set initial status based on submission source
initial_status = ArrivalStatus.BOOKED_IN
if submitted_via == SubmissionSource.ADMIN:
initial_status = ArrivalStatus.INBOUND
db_obj = Arrival(
**obj_in.dict(),
**obj_in.dict(exclude={'submitted_via'}),
created_by=created_by,
status=ArrivalStatus.BOOKED_IN
status=initial_status,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
@@ -113,8 +133,12 @@ class CRUDArrival:
old_status = db_obj.status
db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp:
db_obj.landed_dt = timestamp
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == ArrivalStatus.LANDED:
db_obj.landed_dt = current_time
elif status == ArrivalStatus.ARRIVED:
db_obj.arrived_dt = current_time
db.add(db_obj)
db.commit()
+7
View File
@@ -16,6 +16,12 @@ class CRUDCircuit:
Circuit.local_flight_id == local_flight_id
).order_by(Circuit.circuit_timestamp).all()
def get_by_arrival(self, db: Session, arrival_id: int) -> List[Circuit]:
"""Get all circuits for a specific arrival"""
return db.query(Circuit).filter(
Circuit.arrival_id == arrival_id
).order_by(Circuit.circuit_timestamp).all()
def get_multi(
self,
db: Session,
@@ -27,6 +33,7 @@ class CRUDCircuit:
def create(self, db: Session, obj_in: CircuitCreate) -> Circuit:
db_obj = Circuit(
local_flight_id=obj_in.local_flight_id,
arrival_id=obj_in.arrival_id,
circuit_timestamp=obj_in.circuit_timestamp
)
db.add(db_obj)
+22 -4
View File
@@ -47,11 +47,23 @@ class CRUDDeparture:
)
).order_by(Departure.created_dt).all()
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN") -> 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
db_obj = Departure(
**obj_in.dict(),
created_by=created_by,
status=DepartureStatus.BOOKED_OUT
status=initial_status,
contact_dt=contact_dt,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
@@ -113,8 +125,14 @@ class CRUDDeparture:
old_status = db_obj.status
db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp:
db_obj.departed_dt = timestamp
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == DepartureStatus.GROUND:
db_obj.contact_dt = current_time
elif status == DepartureStatus.DEPARTED:
db_obj.departed_dt = current_time
elif status == DepartureStatus.LOCAL:
db_obj.takeoff_dt = current_time
db.add(db_obj)
db.commit()
+33 -4
View File
@@ -26,7 +26,17 @@ class CRUDLocalFlight:
query = db.query(LocalFlight)
if status:
query = query.filter(LocalFlight.status == status)
if status == LocalFlightStatus.CIRCUIT:
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
circuit_statuses = [
LocalFlightStatus.CIRCUIT,
LocalFlightStatus.CIRCUIT_DOWNWIND,
LocalFlightStatus.CIRCUIT_BASE,
LocalFlightStatus.CIRCUIT_FINAL
]
query = query.filter(LocalFlight.status.in_(circuit_statuses))
else:
query = query.filter(LocalFlight.status == status)
if flight_type:
query = query.filter(LocalFlight.flight_type == flight_type)
@@ -74,11 +84,20 @@ class CRUDLocalFlight:
)
).order_by(LocalFlight.created_dt).all()
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN") -> LocalFlight:
from app.models.local_flight import SubmissionSource
# Set initial status based on submission source
initial_status = LocalFlightStatus.BOOKED_OUT
if submitted_via == SubmissionSource.ADMIN:
initial_status = LocalFlightStatus.GROUND
db_obj = LocalFlight(
**obj_in.dict(),
created_by=created_by,
status=LocalFlightStatus.BOOKED_OUT
status=initial_status,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
@@ -144,10 +163,20 @@ class CRUDLocalFlight:
old_status = db_obj.status
db_obj.status = status
# Update flight_type based on status changes
if status == LocalFlightStatus.LOCAL:
db_obj.flight_type = LocalFlightType.LOCAL
elif status == LocalFlightStatus.CIRCUIT:
db_obj.flight_type = LocalFlightType.CIRCUITS
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == LocalFlightStatus.DEPARTED:
if status == LocalFlightStatus.GROUND:
db_obj.contact_dt = current_time
elif status == LocalFlightStatus.DEPARTED:
db_obj.departed_dt = current_time
elif status == LocalFlightStatus.LOCAL:
db_obj.takeoff_dt = current_time
elif status == LocalFlightStatus.LANDED:
db_obj.landed_dt = current_time
# Count circuits from the circuits table and populate the circuits column
+1
View File
@@ -14,6 +14,7 @@ from app.models.journal import JournalEntry
from app.models.local_flight import LocalFlight
from app.models.departure import Departure
from app.models.arrival import Arrival
from app.models.circuit import Circuit
# Set up logging
logging.basicConfig(level=logging.INFO)
+17 -3
View File
@@ -1,14 +1,25 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
from sqlalchemy.ext.declarative import declarative_base
from enum import Enum
from datetime import datetime
from app.db.session import Base
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED"
@@ -21,10 +32,13 @@ class Arrival(Base):
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False)
in_from = Column(String(4), nullable=False, index=True)
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True)
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.INBOUND, nullable=False, index=True)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
eta = Column(DateTime, nullable=True, index=True)
landed_dt = Column(DateTime, nullable=True)
arrived_dt = Column(DateTime, nullable=True) # Time when aircraft parks and shuts down
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+2 -1
View File
@@ -7,6 +7,7 @@ class Circuit(Base):
__tablename__ = "circuits"
id = Column(BigInteger, primary_key=True, autoincrement=True)
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True)
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=True, index=True)
arrival_id = Column(BigInteger, ForeignKey("arrivals.id", ondelete="CASCADE"), nullable=True, index=True)
circuit_timestamp = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
+12 -1
View File
@@ -6,9 +6,16 @@ from datetime import datetime
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED"
@@ -25,6 +32,10 @@ class Departure(Base):
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
departed_dt = Column(DateTime, nullable=True) # Actual departure time
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
departed_dt = Column(DateTime, nullable=True) # Actual departure time (QSY)
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+15
View File
@@ -4,6 +4,11 @@ from enum import Enum
from app.db.session import Base
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
@@ -12,7 +17,13 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
@@ -32,7 +43,11 @@ class LocalFlight(Base):
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+12
View File
@@ -89,3 +89,15 @@ class Aircraft(Base):
clean_reg = Column(String(25), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
class UserAircraft(Base):
__tablename__ = "user_aircraft"
id = Column(Integer, primary_key=True, autoincrement=True)
registration = Column(String(25), nullable=False, unique=True, index=True)
type_code = Column(String(30), nullable=False)
clean_reg = Column(String(25), nullable=False, index=True)
created_by = Column(String(16), nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+21
View File
@@ -6,10 +6,23 @@ from enum import Enum
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalBase(BaseModel):
registration: str
type: Optional[str] = None
@@ -39,6 +52,7 @@ class ArrivalBase(BaseModel):
class ArrivalCreate(ArrivalBase):
eta: Optional[datetime] = None
submitted_via: Optional[SubmissionSource] = SubmissionSource.ADMIN
class ArrivalUpdate(BaseModel):
@@ -47,6 +61,10 @@ class ArrivalUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
in_from: Optional[str] = None
status: Optional[ArrivalStatus] = None
eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None
arrived_dt: Optional[datetime] = None
notes: Optional[str] = None
@@ -61,8 +79,11 @@ class Arrival(ArrivalBase):
created_dt: datetime
eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None
arrived_dt: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True
+2 -1
View File
@@ -4,7 +4,8 @@ from typing import Optional
class CircuitBase(BaseModel):
local_flight_id: int
local_flight_id: Optional[int] = None
arrival_id: Optional[int] = None
circuit_timestamp: datetime
+19
View File
@@ -6,10 +6,17 @@ from enum import Enum
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureBase(BaseModel):
registration: str
type: Optional[str] = None
@@ -48,7 +55,11 @@ class DepartureUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
out_to: Optional[str] = None
status: Optional[DepartureStatus] = None
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
notes: Optional[str] = None
@@ -62,4 +73,12 @@ class Departure(DepartureBase):
status: DepartureStatus
created_dt: datetime
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True
+17
View File
@@ -12,11 +12,22 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
@@ -61,7 +72,9 @@ class LocalFlightUpdate(BaseModel):
duration: Optional[int] = None
status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
circuits: Optional[int] = None
notes: Optional[str] = None
@@ -76,11 +89,15 @@ class LocalFlightInDBBase(LocalFlightBase):
status: LocalFlightStatus
created_dt: datetime
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True
+20
View File
@@ -215,3 +215,23 @@ class Aircraft(AircraftBase):
class Config:
from_attributes = True
# User Aircraft schemas
class UserAircraftBase(BaseModel):
registration: str
type_code: str
clean_reg: str
created_by: str
class UserAircraft(UserAircraftBase):
id: int
class Config:
from_attributes = True
class UserAircraftCreate(BaseModel):
registration: str
type_code: str
+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()
+6
View File
@@ -174,6 +174,12 @@ else
exit 1
fi
echo ""
echo "========================================="
echo "Generating QR Code"
echo "========================================="
python3 /app/generate_qr.py
echo ""
echo "========================================="
echo "Starting Application Server"
+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)
+3 -1
View File
@@ -28,6 +28,7 @@ services:
REDIS_URL: ${REDIS_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING}
ENVIRONMENT: production
WORKERS: "4"
ports:
@@ -35,6 +36,7 @@ services:
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks:
- app_network
extra_hosts:
@@ -48,7 +50,7 @@ services:
cpus: '1'
memory: 1G
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
+1
View File
@@ -48,6 +48,7 @@ services:
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks:
- private_network
- public_network
+528 -99
View File
@@ -29,7 +29,9 @@
⚙️ Admin
</button>
<div class="dropdown-menu" id="adminDropdownMenu">
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
</div>
</div>
@@ -562,7 +564,7 @@
</div>
</form>
<!-- Circuits Section (for CIRCUITS flights only) -->
<!-- Touch & Go Records Section (for all local flight types) -->
<div id="circuits-section" style="display: none; margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>✈️ Touch & Go Records</h3>
<div id="circuits-list" style="margin-top: 1rem;">
@@ -1048,6 +1050,86 @@
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<!-- User Aircraft Management Modal -->
<div id="userAircraftModal" class="modal">
<div class="modal-content" style="max-width: 1000px;">
<div class="modal-header">
<h2>User Aircraft Management</h2>
<button class="close" onclick="closeModal('userAircraftModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions" style="margin-bottom: 1rem;">
<div class="form-group" style="display: inline-block; margin-right: 1rem;">
<label for="user-aircraft-search" style="display: inline; margin-right: 0.5rem;">Search:</label>
<input type="text" id="user-aircraft-search" placeholder="Filter by registration or type..." style="width: 250px;">
</div>
<button class="btn btn-info" onclick="loadUserAircraft()">
🔄 Refresh
</button>
</div>
<div id="user-aircraft-loading" class="loading">
<div class="spinner"></div>
Loading user aircraft...
</div>
<div id="user-aircraft-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Registration</th>
<th>Type</th>
<th>Added By</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-aircraft-table-body">
</tbody>
</table>
</div>
<div id="user-aircraft-no-data" class="no-data" style="display: none;">
<h3>No user aircraft found</h3>
<p>No custom aircraft types have been saved yet.</p>
</div>
</div>
</div>
</div>
<!-- User Aircraft Edit Modal -->
<div id="userAircraftEditModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="user-aircraft-edit-title">Edit User Aircraft</h2>
<button class="close" onclick="closeModal('userAircraftEditModal')">&times;</button>
</div>
<div class="modal-body">
<form id="user-aircraft-edit-form">
<div class="form-group full-width">
<label for="edit-aircraft-registration">Registration *</label>
<input type="text" id="edit-aircraft-registration" name="registration" required readonly style="background-color: #f5f5f5;">
</div>
<div class="form-group full-width">
<label for="edit-aircraft-type">Aircraft Type *</label>
<input type="text" id="edit-aircraft-type" name="type_code" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('userAircraftEditModal')">
Cancel
</button>
<button type="submit" class="btn btn-warning">
💾 Save Changes
</button>
<button type="button" class="btn btn-danger" onclick="deleteUserAircraft()">
🗑️ Delete
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Timestamp Modal for Landing/Departure -->
<div id="timestampModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
@@ -1494,6 +1576,12 @@
openNewPPRModal();
}
// Press 'g' to book out local flight starting with G
if (e.key === 'g' || e.key === 'G') {
e.preventDefault();
openLocalFlightModal('LOCAL', 'G');
}
// Press 'l' to book out local flight (LOCAL type)
if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
@@ -1749,8 +1837,8 @@
const today = new Date().toISOString().split('T')[0];
const bookedInToday = bookedInArrivals
.filter(arrival => {
// Only include arrivals booked in today (created_dt) with BOOKED_IN status
if (!arrival.created_dt || arrival.status !== 'BOOKED_IN') return false;
// Only include arrivals booked in today (created_dt) with INBOUND, LOCAL, or CIRCUIT status
if (!arrival.created_dt || !['INBOUND', 'LOCAL', 'CIRCUIT'].includes(arrival.status)) return false;
const bookedDate = arrival.created_dt.split('T')[0];
return bookedDate === today;
})
@@ -1772,7 +1860,7 @@
document.getElementById('arrivals-loading').style.display = 'none';
}
// Load departures (LANDED status for PPR, BOOKED_OUT only for local flights)
// Load departures (LANDED status for PPR, GROUND/LOCAL for local flights)
async function loadDepartures() {
document.getElementById('departures-loading').style.display = 'block';
document.getElementById('departures-table-content').style.display = 'none';
@@ -1780,10 +1868,15 @@
try {
// Load PPR departures, local flight departures, and airport departures simultaneously
const [pprResponse, localResponse, depResponse] = await Promise.all([
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000')
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
]);
if (!pprResponse.ok) {
@@ -1791,6 +1884,18 @@
}
const allPPRs = await pprResponse.json();
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
const localCircuit = localCircuitResponse.ok ? await localCircuitResponse.json() : [];
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
// Combine local flights
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal, ...localCircuit];
// Combine departures
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
const today = new Date().toISOString().split('T')[0];
// Filter for PPR departures with ETD today and LANDED status only
@@ -1803,33 +1908,26 @@
return etdDate === today;
});
// Add local flights (BOOKED_OUT status - ready to go) - only those booked out today
if (localResponse.ok) {
const localFlights = await localResponse.json();
const today = new Date().toISOString().split('T')[0];
const localDepartures = localFlights
.filter(flight => {
// Only include flights booked out today (created_dt)
if (!flight.created_dt) return false;
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
})
.map(flight => ({
...flight,
isLocalFlight: true // Flag to distinguish from PPR
}));
departures.push(...localDepartures);
}
// Add departures to other airports (BOOKED_OUT status)
if (depResponse.ok) {
const depFlights = await depResponse.json();
const depDepartures = depFlights.map(flight => ({
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
const localDepartures = allLocalFlights
.filter(flight => {
// Only include flights booked out today (created_dt)
if (!flight.created_dt) return false;
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
})
.map(flight => ({
...flight,
isDeparture: true // Flag to distinguish from PPR
isLocalFlight: true // Flag to distinguish from PPR
}));
departures.push(...depDepartures);
}
departures.push(...localDepartures);
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
const depDepartures = allDepartures.map(flight => ({
...flight,
isDeparture: true // Flag to distinguish from PPR
}));
departures.push(...depDepartures);
displayDepartures(departures);
} catch (error) {
@@ -2019,7 +2117,7 @@
if (isLocal) {
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold;" title="Submitted by Pilot Online">O</span>' : ''}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">-</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
@@ -2027,7 +2125,7 @@
} else if (isDeparture) {
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold;" title="Submitted by Pilot Online">O</span>' : ''}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
@@ -2336,7 +2434,7 @@
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
acType = flight.type;
typeIcon = '';
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
fromDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
// Calculate ETA: use departed_dt (actual departure) if available, otherwise etd (planned departure)
@@ -2351,22 +2449,17 @@
pob = flight.pob || '-';
fuel = '-';
// For circuits, add a circuit button
// Allow touch and go for all local flight types
let circuitButton = '';
if (flight.flight_type === 'CIRCUITS') {
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
}
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
CANCEL
</button>
`;
} else if (isBookedIn) {
// Booked-in arrival display
@@ -2376,7 +2469,7 @@
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
acType = flight.type;
typeIcon = '';
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
// Lookup airport name for in_from
let fromDisplay_temp = flight.in_from;
@@ -2389,14 +2482,58 @@
eta = flight.eta ? formatTimeOnly(flight.eta) : (flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-');
pob = flight.pob || '-';
fuel = '-';
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
CANCEL
</button>
`;
// Different action buttons based on status
if (flight.status === 'INBOUND') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Mark as Local">
LOCAL
</button>
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
CANCEL
</button>
`;
} else if (flight.status === 'LOCAL') {
// Arrival in local area - show circuit and land buttons
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Join Circuit">
CIRCUIT
</button>
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
`;
} else if (flight.status === 'CIRCUIT') {
// Arrival in circuit - show local, T&G and land buttons
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
LOCAL
</button>
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
`;
} else {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
CANCEL
</button>
`;
}
} else {
// PPR display
if (flight.ac_call && flight.ac_call.trim()) {
@@ -2421,9 +2558,6 @@
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${flight.id}, 'CANCELED')" title="Cancel Arrival">
CANCEL
</button>
`;
}
@@ -2467,6 +2601,7 @@
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
const isArrival = flight.isArrival;
// Click handler that routes to correct modal
row.onclick = () => {
@@ -2474,6 +2609,8 @@
openLocalFlightEditModal(flight.id);
} else if (isDeparture) {
openDepartureEditModal(flight.id);
} else if (isArrival) {
openArrivalEditModal(flight.id);
} else {
openPPRModal(flight.id);
}
@@ -2495,7 +2632,7 @@
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
typeIcon = '';
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
toDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
pob = flight.pob || '-';
@@ -2505,28 +2642,47 @@
// Action buttons for local flight
if (flight.status === 'BOOKED_OUT') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
TAKE OFF
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
CONTACT
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
CANCEL
`;
} else if (flight.status === 'GROUND') {
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
const takeoffTitle = flight.flight_type === 'CIRCUITS' ? 'Mark as Circuit' : 'Mark as Local';
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="${takeoffTitle}">
TAKE OFF
</button>
`;
} else if (flight.status === 'DEPARTED') {
// For circuits, add a circuit button; for other flights, just show land button
let circuitButton = '';
if (flight.flight_type === 'CIRCUITS') {
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
}
// Allow touch and go for all local flight types
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
CANCEL
`;
} else if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
REJOIN
</button>
`;
} else if (flight.status === 'CIRCUIT') {
// Circuit traffic - show LOCAL, T&G and LAND buttons
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
LOCAL
</button>
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
LAND
</button>
`;
} else {
@@ -2539,7 +2695,7 @@
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
typeIcon = '';
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
toDisplay = flight.out_to || '-';
if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
toDisplay = await getAirportDisplay(flight.out_to);
@@ -2552,11 +2708,20 @@
// Action buttons for departure
if (flight.status === 'BOOKED_OUT') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, false, true)" title="Contact Pilot">
CONTACT
</button>
`;
} else if (flight.status === 'GROUND') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('LOCAL', ${flight.id}, false, true)" title="Mark as Local">
TAKE OFF
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateDepartureStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel">
CANCEL
`;
} else if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
QSY
</button>
`;
} else if (flight.status === 'DEPARTED') {
@@ -2564,6 +2729,42 @@
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else if (isArrival) {
// Arrival display
if (flight.callsign && flight.callsign.trim()) {
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
typeIcon = '<span style="color: #228b22; font-weight: bold; font-size: 0.9em;" title="Arrival">A</span>';
toDisplay = `<i>Arrival from ${flight.in_from || '?'}</i>`;
etd = flight.eta ? formatTimeOnly(flight.eta) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
pob = flight.pob || '-';
fuel = '-';
landedDt = flight.arrived_dt ? formatTimeOnly(flight.arrived_dt) : '-';
// Action buttons for arrival
if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
REJOIN
</button>
`;
} else if (flight.status === 'CIRCUIT') {
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
LOCAL
</button>
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal(null, ${flight.id})" title="Record Touch & Go">
T&G
</button>
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LANDED')" title="Mark as Landed">
LAND
</button>
`;
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else {
// PPR display
if (flight.ac_call && flight.ac_call.trim()) {
@@ -2585,9 +2786,6 @@
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
TAKE OFF
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${flight.id}, 'CANCELED')" title="Cancel Departure">
CANCEL
</button>
`;
}
@@ -2647,6 +2845,9 @@
document.getElementById('ppr-form').reset();
document.getElementById('ppr-id').value = '';
// Clear the unsaved aircraft flag
document.getElementById('ppr-form').removeAttribute('data-unsaved-aircraft');
// Set default ETA and ETD
const now = new Date();
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
@@ -2898,6 +3099,12 @@
} else if (status === 'DEPARTED') {
modalTitle.textContent = 'Confirm Departure Time';
submitBtn.textContent = '🛫 Confirm Departure';
} else if (status === 'GROUND') {
modalTitle.textContent = 'Confirm Contact Time';
submitBtn.textContent = '📞 Confirm Contact';
} else if (status === 'LOCAL') {
modalTitle.textContent = 'Confirm Takeoff Time';
submitBtn.textContent = '🛫 Confirm Takeoff';
}
// Set default timestamp to current time
@@ -2919,8 +3126,12 @@
}
// Circuit modal functions
function showCircuitModal() {
if (!currentLocalFlightId) return;
function showCircuitModal(localFlightId = null, arrivalId = null) {
if (!localFlightId && !arrivalId) return;
// Set the current IDs
currentLocalFlightId = localFlightId;
currentArrivalId = arrivalId;
// Set default timestamp to current time
const now = new Date();
@@ -2937,13 +3148,15 @@
function closeCircuitModal() {
document.getElementById('circuitModal').style.display = 'none';
document.getElementById('circuit-form').reset();
currentLocalFlightId = null;
currentArrivalId = null;
}
// Circuit form submission
document.getElementById('circuit-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!currentLocalFlightId || !accessToken) return;
if ((!currentLocalFlightId && !currentArrivalId) || !accessToken) return;
const circuitTimestampInput = document.getElementById('circuit-timestamp').value;
if (!circuitTimestampInput) {
@@ -2956,15 +3169,23 @@
const localDate = new Date(circuitTimestampInput);
const circuitTimestamp = localDate.toISOString();
const requestBody = {
circuit_timestamp: circuitTimestamp
};
// Add the appropriate ID based on what we're tracking
if (currentLocalFlightId) {
requestBody.local_flight_id = currentLocalFlightId;
} else if (currentArrivalId) {
requestBody.arrival_id = currentArrivalId;
}
const response = await authenticatedFetch('/api/v1/circuits/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
local_flight_id: currentLocalFlightId,
circuit_timestamp: circuitTimestamp
})
body: JSON.stringify(requestBody)
});
if (!response.ok) {
@@ -3063,6 +3284,9 @@
if (!accessToken) return;
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const pprData = {};
@@ -3479,6 +3703,182 @@
}
});
// ==================== USER AIRCRAFT MANAGEMENT ====================
async function openUserAircraftModal() {
if (!accessToken) return;
document.getElementById('userAircraftModal').style.display = 'block';
await loadUserAircraft();
// Set up search functionality
const searchInput = document.getElementById('user-aircraft-search');
searchInput.addEventListener('input', filterUserAircraft);
}
async function loadUserAircraft() {
if (!accessToken) return;
document.getElementById('user-aircraft-loading').style.display = 'block';
document.getElementById('user-aircraft-table-content').style.display = 'none';
document.getElementById('user-aircraft-no-data').style.display = 'none';
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft');
if (!response.ok) {
throw new Error('Failed to fetch user aircraft');
}
const userAircraft = await response.json();
displayUserAircraft(userAircraft);
} catch (error) {
console.error('Error loading user aircraft:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading user aircraft', true);
}
}
document.getElementById('user-aircraft-loading').style.display = 'none';
}
function displayUserAircraft(userAircraft) {
const tbody = document.getElementById('user-aircraft-table-body');
const searchInput = document.getElementById('user-aircraft-search');
// Store original data for filtering
tbody._originalData = userAircraft;
if (userAircraft.length === 0) {
document.getElementById('user-aircraft-no-data').style.display = 'block';
return;
}
// Apply current filter if any
const filterText = searchInput.value.trim().toLowerCase();
const filteredAircraft = filterText ?
userAircraft.filter(ua =>
ua.registration.toLowerCase().includes(filterText) ||
ua.type_code.toLowerCase().includes(filterText) ||
ua.created_by.toLowerCase().includes(filterText)
) : userAircraft;
if (filteredAircraft.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 2rem;">No aircraft match the search criteria</td></tr>';
} else {
tbody.innerHTML = '';
filteredAircraft.forEach(aircraft => {
const row = document.createElement('tr');
// Format created date
const createdDate = aircraft.created_at ? formatDateTime(aircraft.created_at) : '-';
row.innerHTML = `
<td><strong>${aircraft.registration}</strong></td>
<td>${aircraft.type_code}</td>
<td>${aircraft.created_by}</td>
<td>${createdDate}</td>
<td>
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); openUserAircraftEditModal('${aircraft.registration}', '${aircraft.type_code}')" title="Edit Aircraft">
✏️
</button>
</td>
`;
tbody.appendChild(row);
});
}
document.getElementById('user-aircraft-table-content').style.display = 'block';
}
function filterUserAircraft() {
const tbody = document.getElementById('user-aircraft-table-body');
if (tbody._originalData) {
displayUserAircraft(tbody._originalData);
}
}
function openUserAircraftEditModal(registration, typeCode) {
document.getElementById('edit-aircraft-registration').value = registration;
document.getElementById('edit-aircraft-type').value = typeCode;
document.getElementById('user-aircraft-edit-title').textContent = `Edit ${registration}`;
document.getElementById('userAircraftEditModal').style.display = 'block';
// Auto-focus on type field
setTimeout(() => {
document.getElementById('edit-aircraft-type').focus();
}, 100);
}
// User aircraft edit form submission
document.getElementById('user-aircraft-edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!accessToken) return;
const registration = document.getElementById('edit-aircraft-registration').value.trim();
const typeCode = document.getElementById('edit-aircraft-type').value.trim();
if (!registration || !typeCode) {
showNotification('Registration and type are required', true);
return;
}
try {
const response = await authenticatedFetch(`/api/v1/aircraft/user-aircraft/${registration}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registration: registration,
type_code: typeCode
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to update aircraft');
}
closeModal('userAircraftEditModal');
await loadUserAircraft(); // Refresh list
showNotification('Aircraft updated successfully!');
} catch (error) {
console.error('Error updating aircraft:', error);
showNotification(`Error updating aircraft: ${error.message}`, true);
}
});
async function deleteUserAircraft() {
if (!accessToken) return;
const registration = document.getElementById('edit-aircraft-registration').value.trim();
if (!confirm(`Are you sure you want to delete the aircraft entry for ${registration}? This action cannot be undone.`)) {
return;
}
try {
const response = await authenticatedFetch(`/api/v1/aircraft/user-aircraft/${registration}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to delete aircraft');
}
closeModal('userAircraftEditModal');
await loadUserAircraft(); // Refresh list
showNotification('Aircraft deleted successfully!');
} catch (error) {
console.error('Error deleting aircraft:', error);
showNotification(`Error deleting aircraft: ${error.message}`, true);
}
}
// Update user role detection and UI visibility
async function updateUserRole() {
if (!accessToken) {
@@ -3496,16 +3896,27 @@
// Show user management in dropdown only for administrators
const userManagementDropdown = document.getElementById('user-management-dropdown');
// Show user aircraft for operators and administrators
const userAircraftDropdown = document.getElementById('user-aircraft-dropdown');
if (currentUserRole && currentUserRole.toUpperCase() === 'ADMINISTRATOR') {
userManagementDropdown.style.display = 'block';
if (userManagementDropdown) userManagementDropdown.style.display = 'block';
if (userAircraftDropdown) userAircraftDropdown.style.display = 'block';
} else if (currentUserRole && currentUserRole.toUpperCase() === 'OPERATOR') {
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
if (userAircraftDropdown) userAircraftDropdown.style.display = 'block';
} else {
userManagementDropdown.style.display = 'none';
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
if (userAircraftDropdown) userAircraftDropdown.style.display = 'none';
}
}
} catch (error) {
console.error('Error updating user role:', error);
// Hide user management by default on error
document.getElementById('user-management-dropdown').style.display = 'none';
// Hide admin features by default on error
const userManagementDropdown = document.getElementById('user-management-dropdown');
const userAircraftDropdown = document.getElementById('user-aircraft-dropdown');
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
if (userAircraftDropdown) userAircraftDropdown.style.display = 'none';
}
}
@@ -3554,22 +3965,29 @@
});
// Local Flight (Book Out) Modal Functions
function openLocalFlightModal(flightType = 'LOCAL') {
function openLocalFlightModal(flightType = 'LOCAL', prefillReg = '') {
document.getElementById('local-flight-form').reset();
document.getElementById('local-flight-id').value = '';
document.getElementById('local-flight-modal-title').textContent = 'Book Out';
document.getElementById('local_flight_type').value = flightType;
document.getElementById('localFlightModal').style.display = 'block';
// Clear the unsaved aircraft flag
document.getElementById('local-flight-form').removeAttribute('data-unsaved-aircraft');
// Clear aircraft lookup results
clearLocalAircraftLookup();
// Update destination field visibility based on flight type
handleFlightTypeChange(flightType);
// Auto-focus on registration field
// Auto-focus on registration field and prefill if provided
setTimeout(() => {
document.getElementById('local_registration').focus();
const regField = document.getElementById('local_registration');
regField.focus();
if (prefillReg) {
regField.value = prefillReg;
}
}, 100);
}
@@ -3578,6 +3996,9 @@
document.getElementById('book-in-id').value = '';
document.getElementById('bookInModal').style.display = 'block';
// Clear the unsaved aircraft flag
document.getElementById('book-in-form').removeAttribute('data-unsaved-aircraft');
// Clear aircraft lookup results
clearBookInAircraftLookup();
clearBookInArrivalAirportLookup();
@@ -3596,6 +4017,9 @@
document.getElementById('overflight-id').value = '';
document.getElementById('overflightModal').style.display = 'block';
// Clear the unsaved aircraft flag
document.getElementById('overflight-form').removeAttribute('data-unsaved-aircraft');
// Clear aircraft lookup results
clearOverflightAircraftLookup();
clearOverflightDepartureAirportLookup();
@@ -3994,14 +4418,10 @@
document.getElementById('local-flight-edit-title').textContent = `${flight.registration} - ${flight.flight_type}`;
// Load and display circuits if this is a CIRCUITS flight
// Load and display circuits for all local flight types
const circuitsSection = document.getElementById('circuits-section');
if (flight.flight_type === 'CIRCUITS') {
circuitsSection.style.display = 'block';
loadCircuitsDisplay(flight.id);
} else {
circuitsSection.style.display = 'none';
}
circuitsSection.style.display = 'block';
loadCircuitsDisplay(flight.id);
document.getElementById('localFlightEditModal').style.display = 'block';
@@ -4187,8 +4607,8 @@
// Show/hide quick action buttons based on status
const landedBtn = document.getElementById('arrival-btn-landed');
const cancelBtn = document.getElementById('arrival-btn-cancel');
landedBtn.style.display = arrival.status === 'BOOKED_IN' ? 'block' : 'none';
cancelBtn.style.display = arrival.status === 'BOOKED_IN' ? 'block' : 'none';
landedBtn.style.display = arrival.status === 'INBOUND' ? 'block' : 'none';
cancelBtn.style.display = arrival.status === 'INBOUND' ? 'block' : 'none';
// Show modal
document.getElementById('arrivalEditModal').style.display = 'block';
@@ -4529,6 +4949,9 @@
if (!accessToken) return;
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const flightType = formData.get('flight_type');
const flightData = {};
@@ -4612,6 +5035,9 @@
if (!accessToken) return;
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const arrivalData = {};
@@ -4691,6 +5117,9 @@
if (!accessToken) return;
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const overflightData = {};
+5695
View File
File diff suppressed because it is too large Load Diff
+1147
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -692,6 +692,9 @@
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const pprData = {};
+45 -1
View File
@@ -178,6 +178,35 @@
left: 28px;
}
/* QR code for booking */
.qr-code-container {
position: absolute;
left: 300px;
top: 50%;
transform: translateY(-50%);
background: white;
padding: 5px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.qr-code-container .qr-label {
font-size: 12px;
font-weight: bold;
color: #333;
white-space: nowrap;
}
.qr-code-container img {
display: block;
max-width: 120px;
height: auto;
}
/* Santa hat styles */
.santa-hat {
position: absolute;
@@ -357,6 +386,10 @@
<body>
<header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<div class="qr-code-container">
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
<div class="qr-label">Book Out</div>
</div>
<h1>Flight Information</h1>
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header>
@@ -666,7 +699,7 @@
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
sortTime = arrival.landed_dt;
} else {
// Show ETA if BOOKED_IN
// Show ETA if INBOUND
const time = convertToLocalTime(arrival.eta);
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
sortTime = arrival.eta;
@@ -847,11 +880,22 @@
return typeMap[flightType] || flightType;
}
// Generate QR code for booking page
function generateBookingQR() {
const qrImg = document.getElementById('bookingQR');
if (qrImg) {
qrImg.src = '/assets/booking-qr.png';
}
}
// Load data on page load
window.addEventListener('load', function() {
// Initialize Christmas mode
initChristmasMode();
// Load booking QR code
generateBookingQR();
loadArrivals();
loadDepartures();
+137 -4
View File
@@ -163,15 +163,26 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
const resultsDiv = document.getElementById(resultsId);
if (config.isAircraft) {
// Aircraft lookup: auto-populate on single match, format input on no match
// Aircraft lookup: auto-populate on single match, mark form for auto-save on no match
if (!results || results.length === 0) {
// Format the aircraft registration and auto-populate
// Format the aircraft registration
const formatted = formatAircraftRegistration(searchTerm);
const field = document.getElementById(fieldId);
if (field) {
field.value = formatted;
// Mark the form for auto-saving this aircraft
const form = field.closest('form');
if (form) {
form.setAttribute('data-unsaved-aircraft', fieldId);
}
}
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
// Show message that type will be saved
resultsDiv.innerHTML = `
<div class="aircraft-no-match">
No match found - aircraft type will be saved automatically when you submit
</div>
`;
} else if (results.length === 1) {
// Single match - auto-populate
const aircraft = results[0];
@@ -183,7 +194,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Auto-populate the form fields
const field = document.getElementById(fieldId);
if (field) field.value = aircraft.registration;
if (field) {
field.value = aircraft.registration;
// Clear the unsaved aircraft flag since we found a match
const form = field.closest('form');
if (form) {
form.removeAttribute('data-unsaved-aircraft');
}
}
// Also populate type field
let typeFieldId;
@@ -208,6 +226,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
Multiple matches found (${results.length}) - please be more specific
</div>
`;
// Clear the unsaved aircraft flag since multiple matches found
const field = document.getElementById(fieldId);
if (field) {
const form = field.closest('form');
if (form) {
form.removeAttribute('data-unsaved-aircraft');
}
}
}
} else {
// Airport lookup: show list of options with keyboard navigation
@@ -501,3 +527,110 @@ function selectBookInAircraft(registration) {
function selectBookInArrivalAirport(icaoCode) {
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
}
// Save user aircraft type for future lookups
async function saveUserAircraft(registrationFieldId, resultsDivId) {
const regField = document.getElementById(registrationFieldId);
if (!regField || !regField.value.trim()) {
showNotification('Please enter a registration first', true);
return;
}
// Determine the type field ID based on registration field
let typeFieldId;
if (registrationFieldId === 'ac_reg') {
typeFieldId = 'ac_type';
} else if (registrationFieldId === 'local_registration') {
typeFieldId = 'local_type';
} else if (registrationFieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (registrationFieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
const typeField = document.getElementById(typeFieldId);
if (!typeField || !typeField.value.trim()) {
showNotification('Please enter an aircraft type first', true);
return;
}
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registration: regField.value.trim(),
type_code: typeField.value.trim()
})
});
if (response.ok) {
const data = await response.json();
showNotification('Aircraft type saved for future use');
// Clear the results div
const resultsDiv = document.getElementById(resultsDivId);
if (resultsDiv) {
resultsDiv.innerHTML = '';
}
} else {
const error = await response.json();
showNotification(error.detail || 'Failed to save aircraft', true);
}
} catch (error) {
console.error('Error saving aircraft:', error);
showNotification('Error saving aircraft', true);
}
}
// Auto-save unsaved aircraft before form submission
async function autoSaveUnsavedAircraft(form) {
const unsavedFieldId = form.getAttribute('data-unsaved-aircraft');
if (!unsavedFieldId) return; // No unsaved aircraft to save
const regField = document.getElementById(unsavedFieldId);
if (!regField || !regField.value.trim()) return;
// Determine the type field ID
let typeFieldId;
if (unsavedFieldId === 'ac_reg') {
typeFieldId = 'ac_type';
} else if (unsavedFieldId === 'local_registration') {
typeFieldId = 'local_type';
} else if (unsavedFieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (unsavedFieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
const typeField = document.getElementById(typeFieldId);
if (!typeField || !typeField.value.trim()) return;
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registration: regField.value.trim(),
type_code: typeField.value.trim()
})
});
if (response.ok) {
// Successfully saved, remove the flag
form.removeAttribute('data-unsaved-aircraft');
console.log('Auto-saved aircraft type for', regField.value.trim());
} else if (response.status === 400) {
// Already exists, just remove the flag
form.removeAttribute('data-unsaved-aircraft');
} else {
console.error('Failed to auto-save aircraft');
}
} catch (error) {
console.error('Error auto-saving aircraft:', error);
}
}