Getting there

This commit is contained in:
2025-12-12 11:18:28 -05:00
parent f7467690e4
commit dbb285fa20
15 changed files with 1080 additions and 36 deletions

View File

@@ -20,13 +20,13 @@ depends_on = None
def upgrade() -> None:
"""
Create local_flights table for tracking aircraft that book out locally.
Create local_flights, departures, and arrivals tables.
"""
op.create_table('local_flights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
@@ -43,16 +43,70 @@ def upgrade() -> None:
mysql_collate='utf8mb4_unicode_ci'
)
# Create indexes for frequently queried columns
# Create indexes for local_flights
op.create_index('idx_registration', 'local_flights', ['registration'])
op.create_index('idx_flight_type', 'local_flights', ['flight_type'])
op.create_index('idx_status', 'local_flights', ['status'])
op.create_index('idx_booked_out_dt', 'local_flights', ['booked_out_dt'])
op.create_index('idx_created_by', 'local_flights', ['created_by'])
# Create departures table for non-PPR departures to other airports
op.create_table('departures',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('out_to', sa.String(length=64), nullable=False),
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'CANCELLED', name='departuresstatus'), nullable=False, server_default='BOOKED_OUT'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('booked_out_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('departure_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_dep_registration', 'departures', ['registration'])
op.create_index('idx_dep_out_to', 'departures', ['out_to'])
op.create_index('idx_dep_status', 'departures', ['status'])
op.create_index('idx_dep_booked_out_dt', 'departures', ['booked_out_dt'])
op.create_index('idx_dep_created_by', 'departures', ['created_by'])
# Create arrivals table for non-PPR arrivals from elsewhere
op.create_table('arrivals',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('pob', sa.Integer(), nullable=False),
sa.Column('in_from', sa.String(length=64), nullable=False),
sa.Column('status', sa.Enum('BOOKED_IN', 'LANDED', 'CANCELLED', name='arrivalsstatus'), nullable=False, server_default='BOOKED_IN'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('booked_in_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('landed_dt', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
op.create_index('idx_arr_registration', 'arrivals', ['registration'])
op.create_index('idx_arr_in_from', 'arrivals', ['in_from'])
op.create_index('idx_arr_status', 'arrivals', ['status'])
op.create_index('idx_arr_booked_in_dt', 'arrivals', ['booked_in_dt'])
op.create_index('idx_arr_created_by', 'arrivals', ['created_by'])
def downgrade() -> None:
"""
Drop the local_flights table.
Drop the local_flights, departures, and arrivals tables.
"""
op.drop_table('arrivals')
op.drop_table('departures')
op.drop_table('local_flights')

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.crud.crud_ppr import ppr as crud_ppr
from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.crud.crud_departure import departure as crud_departure
from app.crud.crud_arrival import arrival as crud_arrival
from app.schemas.ppr import PPRPublic
from app.models.local_flight import LocalFlightStatus
from app.models.departure import DepartureStatus
from app.models.arrival import ArrivalStatus
from datetime import date
router = APIRouter()
@@ -56,7 +60,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
@router.get("/departures")
async def get_public_departures(db: Session = Depends(get_db)):
"""Get today's departures for public display (PPR and local flights)"""
"""Get today's departures for public display (PPR, local flights, and departures to other airports)"""
departures = crud_ppr.get_departures_today(db)
# Convert PPR departures to dictionaries
@@ -70,7 +74,8 @@ async def get_public_departures(db: Session = Depends(get_db)):
'etd': departure.etd,
'departed_dt': departure.departed_dt,
'status': departure.status.value,
'isLocalFlight': False
'isLocalFlight': False,
'isDeparture': False
})
# Add local flights with BOOKED_OUT status
@@ -91,7 +96,29 @@ async def get_public_departures(db: Session = Depends(get_db)):
'departed_dt': None,
'status': 'BOOKED_OUT',
'isLocalFlight': True,
'flight_type': flight.flight_type.value
'flight_type': flight.flight_type.value,
'isDeparture': False
})
# Add departures to other airports with BOOKED_OUT status
departures_to_airports = crud_departure.get_multi(
db,
status=DepartureStatus.BOOKED_OUT,
limit=1000
)
# Convert departures to match the format for display
for dep in departures_to_airports:
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.booked_out_dt,
'departed_dt': None,
'status': 'BOOKED_OUT',
'isLocalFlight': False,
'isDeparture': True
})
return departures_list

View File

@@ -0,0 +1,104 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.arrival import Arrival, ArrivalStatus
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
class CRUDArrival:
def get(self, db: Session, arrival_id: int) -> Optional[Arrival]:
return db.query(Arrival).filter(Arrival.id == arrival_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[ArrivalStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Arrival]:
query = db.query(Arrival)
if status:
query = query.filter(Arrival.status == status)
if date_from:
query = query.filter(func.date(Arrival.booked_in_dt) >= date_from)
if date_to:
query = query.filter(func.date(Arrival.booked_in_dt) <= date_to)
return query.order_by(desc(Arrival.booked_in_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[Arrival]:
"""Get today's arrivals (booked in or landed)"""
today = date.today()
return db.query(Arrival).filter(
and_(
func.date(Arrival.booked_in_dt) == today,
or_(
Arrival.status == ArrivalStatus.BOOKED_IN,
Arrival.status == ArrivalStatus.LANDED
)
)
).order_by(Arrival.booked_in_dt).all()
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
db_obj = Arrival(
**obj_in.dict(),
created_by=created_by,
status=ArrivalStatus.BOOKED_IN
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate) -> Arrival:
update_data = obj_in.dict(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_status(
self,
db: Session,
arrival_id: int,
status: ArrivalStatus,
timestamp: Optional[datetime] = None
) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp:
db_obj.landed_dt = timestamp
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
db_obj.status = ArrivalStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
arrival = CRUDArrival()

View File

@@ -0,0 +1,104 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.departure import Departure, DepartureStatus
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
class CRUDDeparture:
def get(self, db: Session, departure_id: int) -> Optional[Departure]:
return db.query(Departure).filter(Departure.id == departure_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[DepartureStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Departure]:
query = db.query(Departure)
if status:
query = query.filter(Departure.status == status)
if date_from:
query = query.filter(func.date(Departure.booked_out_dt) >= date_from)
if date_to:
query = query.filter(func.date(Departure.booked_out_dt) <= date_to)
return query.order_by(desc(Departure.booked_out_dt)).offset(skip).limit(limit).all()
def get_departures_today(self, db: Session) -> List[Departure]:
"""Get today's departures (booked out or departed)"""
today = date.today()
return db.query(Departure).filter(
and_(
func.date(Departure.booked_out_dt) == today,
or_(
Departure.status == DepartureStatus.BOOKED_OUT,
Departure.status == DepartureStatus.DEPARTED
)
)
).order_by(Departure.booked_out_dt).all()
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
db_obj = Departure(
**obj_in.dict(),
created_by=created_by,
status=DepartureStatus.BOOKED_OUT
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate) -> Departure:
update_data = obj_in.dict(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_status(
self,
db: Session,
departure_id: int,
status: DepartureStatus,
timestamp: Optional[datetime] = None
) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp:
db_obj.departure_dt = timestamp
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
db_obj.status = DepartureStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
departure = CRUDDeparture()

View File

@@ -11,6 +11,8 @@ from app.api.api import api_router
# Import models to ensure they're registered with SQLAlchemy
from app.models.ppr import PPRRecord, User, Journal, Airport, Aircraft
from app.models.local_flight import LocalFlight
from app.models.departure import Departure
from app.models.arrival import Arrival
# Set up logging
logging.basicConfig(level=logging.INFO)

View File

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

View File

@@ -0,0 +1,29 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
from sqlalchemy.ext.declarative import declarative_base
from enum import Enum
from datetime import datetime
Base = declarative_base()
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
CANCELLED = "CANCELLED"
class Departure(Base):
__tablename__ = "departures"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=True)
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False)
out_to = Column(String(4), nullable=False, index=True)
status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True)
notes = Column(Text, nullable=True)
booked_out_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
departure_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class LocalFlightStatus(str, Enum):
class LocalFlightBase(BaseModel):
registration: str
type: str # Aircraft type
type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None
pob: int
flight_type: LocalFlightType
@@ -31,11 +31,13 @@ class LocalFlightBase(BaseModel):
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('type')
@validator('type', pre=True, always=False)
def validate_type(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft type is required')
if v is None or (isinstance(v, str) and len(v.strip()) == 0):
return None
if isinstance(v, str):
return v.strip()
return v
@validator('pob')
def validate_pob(cls, v):

View File

@@ -524,7 +524,7 @@
}
/* Airport Lookup Styles */
#arrival-airport-lookup-results, #departure-airport-lookup-results {
#arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
@@ -986,12 +986,12 @@
<div id="local-aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="local_type">Aircraft Type *</label>
<input type="text" id="local_type" name="type" required tabindex="-1" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
<label for="local_type">Aircraft Type</label>
<input type="text" id="local_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
</div>
<div class="form-group">
<label for="local_callsign">Callsign (optional)</label>
<input type="text" id="local_callsign" name="callsign" placeholder="If different from registration" tabindex="4">
<input type="text" id="local_callsign" name="callsign" placeholder="If different from registration" tabindex="6">
</div>
<div class="form-group">
<label for="local_pob">Persons on Board *</label>
@@ -999,12 +999,17 @@
</div>
<div class="form-group">
<label for="local_flight_type">Flight Type *</label>
<select id="local_flight_type" name="flight_type" required tabindex="3">
<select id="local_flight_type" name="flight_type" required tabindex="5" onchange="handleFlightTypeChange(this.value)">
<option value="LOCAL">Local Flight</option>
<option value="CIRCUITS">Circuits</option>
<option value="DEPARTURE">Departure</option>
<option value="DEPARTURE">Departure to Other Airport</option>
</select>
</div>
<div class="form-group" id="departure-destination-group" style="display: none;">
<label for="local_out_to" id="departure-destination-label">Destination Airport</label>
<input type="text" id="local_out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleLocalOutToAirportLookup(this.value)" tabindex="3">
<div id="local-out-to-lookup-results"></div>
</div>
<div class="form-group full-width">
<label for="local_notes">Notes</label>
<textarea id="local_notes" name="notes" rows="3" placeholder="e.g., destination, any special requirements"></textarea>
@@ -1304,6 +1309,27 @@
loadPPRs();
showNotification('Data updated');
}
// Refresh local flights when any local flight event occurs
if (data.type && (data.type.includes('local_flight_'))) {
console.log('Local flight update detected, refreshing...');
loadLocalFlights();
showNotification('Local flight updated');
}
// Refresh departures when any departure event occurs
if (data.type && (data.type.includes('departure_'))) {
console.log('Departure update detected, refreshing...');
loadDepartures();
showNotification('Departure updated');
}
// Refresh arrivals when any arrival event occurs
if (data.type && (data.type.includes('arrival_'))) {
console.log('Arrival update detected, refreshing...');
loadArrivals();
showNotification('Arrival updated');
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
@@ -1521,8 +1547,8 @@
openNewPPRModal();
}
// Press 'b' to book out local flight (LOCAL type)
if (e.key === 'b' || e.key === 'B') {
// Press 'o' to book out local flight (LOCAL type)
if (e.key === 'o' || e.key === 'O') {
e.preventDefault();
openLocalFlightModal('LOCAL');
}
@@ -1724,10 +1750,11 @@
document.getElementById('departures-no-data').style.display = 'none';
try {
// Load PPR departures and local flight departures (BOOKED_OUT only) simultaneously
const [pprResponse, localResponse] = await Promise.all([
// Load PPR departures, local flight departures, and airport departures simultaneously
const [pprResponse, localResponse, depResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000')
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000')
]);
if (!pprResponse.ok) {
@@ -1757,6 +1784,16 @@
departures.push(...localDepartures);
}
// Add departures to other airports (BOOKED_OUT status)
if (depResponse.ok) {
const depFlights = await depResponse.json();
const depDepartures = depFlights.map(flight => ({
...flight,
isDeparture: true // Flag to distinguish from PPR
}));
departures.push(...depDepartures);
}
displayDepartures(departures);
} catch (error) {
console.error('Error loading departures:', error);
@@ -2223,11 +2260,14 @@
for (const flight of departures) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
// Click handler that routes to correct modal
row.onclick = () => {
if (isLocal) {
openLocalFlightEditModal(flight.id);
} else if (isDeparture) {
// TODO: Open departure edit modal
} else {
openPPRModal(flight.id);
}
@@ -2277,6 +2317,37 @@
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else if (isDeparture) {
// Departure to other airport 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>`;
}
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);
}
etd = flight.booked_out_dt ? formatTimeOnly(flight.booked_out_dt) : '-';
pob = flight.pob || '-';
fuel = '-';
landedDt = flight.departure_dt ? formatTimeOnly(flight.departure_dt) : '-';
// 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">
TAKE OFF
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateDepartureStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel">
CANCEL
</button>
`;
} else if (flight.status === 'DEPARTED') {
actionButtons = '<span style="color: #999;">Departed</span>';
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else {
// PPR display
if (flight.ac_call && flight.ac_call.trim()) {
@@ -2305,7 +2376,7 @@
row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td>
<td>${isLocal ? flight.type : flight.ac_type}</td>
<td>${isLocal ? flight.type : isDeparture ? flight.type : flight.ac_type}</td>
<td>${toDisplay}</td>
<td>${etd}</td>
<td>${pob}</td>
@@ -2575,11 +2646,11 @@
}
// Timestamp modal functions
function showTimestampModal(status, pprId = null, isLocalFlight = false) {
function showTimestampModal(status, pprId = null, isLocalFlight = false, isDeparture = false) {
const targetId = pprId || (isLocalFlight ? currentLocalFlightId : currentPPRId);
if (!targetId) return;
pendingStatusUpdate = { status: status, pprId: targetId, isLocalFlight: isLocalFlight };
pendingStatusUpdate = { status: status, pprId: targetId, isLocalFlight: isLocalFlight, isDeparture: isDeparture };
const modalTitle = document.getElementById('timestamp-modal-title');
const submitBtn = document.getElementById('timestamp-submit-btn');
@@ -2627,9 +2698,16 @@
try {
// Determine the correct API endpoint based on flight type
const isLocal = pendingStatusUpdate.isLocalFlight;
const endpoint = isLocal ?
`/api/v1/local-flights/${pendingStatusUpdate.pprId}/status` :
`/api/v1/pprs/${pendingStatusUpdate.pprId}/status`;
const isDeparture = pendingStatusUpdate.isDeparture;
let endpoint;
if (isLocal) {
endpoint = `/api/v1/local-flights/${pendingStatusUpdate.pprId}/status`;
} else if (isDeparture) {
endpoint = `/api/v1/departures/${pendingStatusUpdate.pprId}/status`;
} else {
endpoint = `/api/v1/pprs/${pendingStatusUpdate.pprId}/status`;
}
const response = await fetch(endpoint, {
method: 'PATCH',
@@ -3405,6 +3483,79 @@
document.getElementById('departure-airport-lookup-results').innerHTML = '';
}
function clearLocalOutToAirportLookup() {
document.getElementById('local-out-to-lookup-results').innerHTML = '';
}
// Airport lookup for Book Out modal departure field
function handleLocalOutToAirportLookup(value) {
if (!value.trim()) {
clearLocalOutToAirportLookup();
return;
}
performLocalOutToAirportLookup(value);
}
async function performLocalOutToAirportLookup(codeOrName) {
try {
const cleanInput = codeOrName.trim();
if (cleanInput.length < 2) {
clearLocalOutToAirportLookup();
return;
}
// Call the airport lookup API using same endpoint as PPR modal
const response = await authenticatedFetch(`/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`);
if (!response.ok) {
throw new Error('Failed to fetch airport data');
}
const matches = await response.json();
displayLocalOutToAirportLookupResults(matches, cleanInput);
} catch (error) {
console.error('Local out-to airport lookup error:', error);
document.getElementById('local-out-to-lookup-results').innerHTML =
'<div class="airport-no-match">Lookup failed - will use as entered</div>';
}
}
function displayLocalOutToAirportLookupResults(matches, searchTerm) {
const resultsDiv = document.getElementById('local-out-to-lookup-results');
if (!matches || matches.length === 0) {
resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>';
} else {
// Show matches as clickable options (single or multiple)
const matchText = matches.length === 1 ? 'Match found - click to select:' : 'Multiple matches found - select one:';
const listHtml = matches.map(airport => `
<div class="airport-option" onclick="selectLocalOutToAirport('${airport.icao}')">
<div>
<div class="airport-code">${airport.icao}</div>
<div class="airport-name">${airport.name}</div>
${airport.city ? `<div class="airport-location">${airport.city}, ${airport.country}</div>` : ''}
</div>
</div>
`).join('');
resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;">
${matchText}
</div>
<div class="airport-list">
${listHtml}
</div>
`;
}
}
function selectLocalOutToAirport(icaoCode) {
document.getElementById('local_out_to').value = icaoCode;
clearLocalOutToAirportLookup();
}
// Airport selection functions
function selectArrivalAirport(icaoCode) {
document.getElementById('in_from').value = icaoCode;
@@ -3461,6 +3612,9 @@
// Clear aircraft lookup results
clearLocalAircraftLookup();
// Update destination field visibility based on flight type
handleFlightTypeChange(flightType);
// Auto-focus on registration field
setTimeout(() => {
document.getElementById('local_registration').focus();
@@ -3471,6 +3625,24 @@
document.getElementById('localFlightModal').style.display = 'none';
}
// Handle flight type change to show/hide destination field
function handleFlightTypeChange(flightType) {
const destGroup = document.getElementById('departure-destination-group');
const destInput = document.getElementById('local_out_to');
const destLabel = document.getElementById('departure-destination-label');
if (flightType === 'DEPARTURE') {
destGroup.style.display = 'block';
destInput.required = true;
destLabel.textContent = 'Destination Airport *';
} else {
destGroup.style.display = 'none';
destInput.required = false;
destInput.value = '';
destLabel.textContent = 'Destination Airport';
}
}
// Handle aircraft lookup for local flights
let localAircraftLookupTimeout;
function handleLocalAircraftLookup(registration) {
@@ -3632,6 +3804,30 @@
}
}
// Update status from table for departures
async function updateDepartureStatusFromTable(departureId, status) {
if (!accessToken) return;
try {
const response = await fetch(`/api/v1/departures/${departureId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ status: status })
});
if (!response.ok) throw new Error('Failed to update status');
loadPPRs(); // Refresh display
showNotification(`Departure marked as ${status.toLowerCase()}`);
} catch (error) {
console.error('Error updating status:', error);
showNotification('Error updating departure status', true);
}
}
// Update status from modal (uses currentLocalFlightId)
async function updateLocalFlightStatus(status) {
if (!currentLocalFlightId || !accessToken) return;
@@ -3719,7 +3915,9 @@
if (!accessToken) return;
const formData = new FormData(this);
const flightType = formData.get('flight_type');
const flightData = {};
let endpoint = '/api/v1/local-flights/';
formData.forEach((value, key) => {
// Skip the hidden id field and empty values
@@ -3747,10 +3945,17 @@
}
});
console.log('Submitting flight data:', flightData);
// If DEPARTURE flight type, use departures endpoint instead
if (flightType === 'DEPARTURE') {
endpoint = '/api/v1/departures/';
// Remove flight_type from data and use out_to instead
delete flightData.flight_type;
}
console.log(`Submitting ${endpoint} data:`, flightData);
try {
const response = await fetch('/api/v1/local-flights/', {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -3763,7 +3968,11 @@
let errorMessage = 'Failed to book out flight';
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorMessage;
if (errorData.detail) {
errorMessage = typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail);
} else if (errorData.errors) {
errorMessage = errorData.errors.map(e => e.msg).join(', ');
}
} catch (e) {
const text = await response.text();
console.error('Server response:', text);

View File

@@ -234,9 +234,9 @@
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
// Refresh display when any PPR-related event occurs
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
console.log('PPR update detected, refreshing display...');
// Refresh display when any PPR-related or local flight event occurs
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update' || data.type.includes('local_flight_'))) {
console.log('Flight update detected, refreshing display...');
loadArrivals();
loadDepartures();
}
@@ -375,6 +375,7 @@
// Build rows asynchronously to lookup airport names
const rows = await Promise.all(departures.map(async (departure) => {
const isLocal = departure.isLocalFlight;
const isDeparture = departure.isDeparture;
if (isLocal) {
// Local flight
@@ -384,6 +385,21 @@
const time = convertToLocalTime(departure.etd);
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
return `
<tr>
<td>${aircraftDisplay}</td>
<td>${toDisplay}</td>
<td>${timeDisplay}</td>
</tr>
`;
} else if (isDeparture) {
// Departure to other airport
const aircraftId = departure.ac_call || departure.ac_reg || '';
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
const toDisplay = await getAirportName(departure.out_to || '');
const time = convertToLocalTime(departure.etd);
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
return `
<tr>
<td>${aircraftDisplay}</td>