From dbb285fa2080b05e412561ef2ba1e260458c3322 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Fri, 12 Dec 2025 11:18:28 -0500 Subject: [PATCH] Getting there --- backend/alembic/versions/002_local_flights.py | 62 ++++- backend/app/api/api.py | 4 +- backend/app/api/endpoints/arrivals.py | 167 ++++++++++++ backend/app/api/endpoints/departures.py | 167 ++++++++++++ backend/app/api/endpoints/public.py | 33 ++- backend/app/crud/crud_arrival.py | 104 ++++++++ backend/app/crud/crud_departure.py | 104 ++++++++ backend/app/main.py | 2 + backend/app/models/arrival.py | 29 ++ backend/app/models/departure.py | 29 ++ backend/app/schemas/arrival.py | 66 +++++ backend/app/schemas/departure.py | 66 +++++ backend/app/schemas/local_flight.py | 12 +- web/admin.html | 249 ++++++++++++++++-- web/index.html | 22 +- 15 files changed, 1080 insertions(+), 36 deletions(-) create mode 100644 backend/app/api/endpoints/arrivals.py create mode 100644 backend/app/api/endpoints/departures.py create mode 100644 backend/app/crud/crud_arrival.py create mode 100644 backend/app/crud/crud_departure.py create mode 100644 backend/app/models/arrival.py create mode 100644 backend/app/models/departure.py create mode 100644 backend/app/schemas/arrival.py create mode 100644 backend/app/schemas/departure.py diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py index f59411b..45d477e 100644 --- a/backend/alembic/versions/002_local_flights.py +++ b/backend/alembic/versions/002_local_flights.py @@ -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') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 439e869..8021ec3 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -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"]) \ No newline at end of file diff --git a/backend/app/api/endpoints/arrivals.py b/backend/app/api/endpoints/arrivals.py new file mode 100644 index 0000000..c625004 --- /dev/null +++ b/backend/app/api/endpoints/arrivals.py @@ -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 diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py new file mode 100644 index 0000000..8d46197 --- /dev/null +++ b/backend/app/api/endpoints/departures.py @@ -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 diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index 5d08c05..e7b30f6 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -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 \ No newline at end of file diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py new file mode 100644 index 0000000..202aa4a --- /dev/null +++ b/backend/app/crud/crud_arrival.py @@ -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() diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py new file mode 100644 index 0000000..015a09f --- /dev/null +++ b/backend/app/crud/crud_departure.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 787eaea..dad5715 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py new file mode 100644 index 0000000..a2e2930 --- /dev/null +++ b/backend/app/models/arrival.py @@ -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) diff --git a/backend/app/models/departure.py b/backend/app/models/departure.py new file mode 100644 index 0000000..9f4132b --- /dev/null +++ b/backend/app/models/departure.py @@ -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) diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py new file mode 100644 index 0000000..7081d05 --- /dev/null +++ b/backend/app/schemas/arrival.py @@ -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 diff --git a/backend/app/schemas/departure.py b/backend/app/schemas/departure.py new file mode 100644 index 0000000..710fb45 --- /dev/null +++ b/backend/app/schemas/departure.py @@ -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 diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py index 2fd1351..e5e1e59 100644 --- a/backend/app/schemas/local_flight.py +++ b/backend/app/schemas/local_flight.py @@ -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') - return v.strip() + 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): diff --git a/web/admin.html b/web/admin.html index d093fa5..6905d32 100644 --- a/web/admin.html +++ b/web/admin.html @@ -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 @@
- - + +
- +
@@ -999,12 +999,17 @@
- - +
+
@@ -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 = '-'; } + } else if (isDeparture) { + // Departure to other airport display + if (flight.callsign && flight.callsign.trim()) { + aircraftDisplay = `${flight.callsign}
${flight.registration}`; + } else { + aircraftDisplay = `${flight.registration}`; + } + 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 = ` + + + `; + } else if (flight.status === 'DEPARTED') { + actionButtons = 'Departed'; + } else { + actionButtons = '-'; + } } else { // PPR display if (flight.ac_call && flight.ac_call.trim()) { @@ -2305,7 +2376,7 @@ row.innerHTML = ` ${aircraftDisplay}${notesIndicator} - ${isLocal ? flight.type : flight.ac_type} + ${isLocal ? flight.type : isDeparture ? flight.type : flight.ac_type} ${toDisplay} ${etd} ${pob} @@ -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 = + '
Lookup failed - will use as entered
'; + } + } + + function displayLocalOutToAirportLookupResults(matches, searchTerm) { + const resultsDiv = document.getElementById('local-out-to-lookup-results'); + + if (!matches || matches.length === 0) { + resultsDiv.innerHTML = '
No matches found - will use as entered
'; + } 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 => ` +
+
+
${airport.icao}
+
${airport.name}
+ ${airport.city ? `
${airport.city}, ${airport.country}
` : ''} +
+
+ `).join(''); + + resultsDiv.innerHTML = ` +
+ ${matchText} +
+
+ ${listHtml} +
+ `; + } + } + + 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); diff --git a/web/index.html b/web/index.html index 2fd2673..e60516d 100644 --- a/web/index.html +++ b/web/index.html @@ -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 = `
${escapeHtml(time)}
`; + return ` + + ${aircraftDisplay} + ${toDisplay} + ${timeDisplay} + + `; + } else if (isDeparture) { + // Departure to other airport + const aircraftId = departure.ac_call || departure.ac_reg || ''; + const aircraftDisplay = `${escapeHtml(aircraftId)} (${escapeHtml(departure.ac_type || '')})`; + const toDisplay = await getAirportName(departure.out_to || ''); + const time = convertToLocalTime(departure.etd); + const timeDisplay = `
${escapeHtml(time)}
`; + return ` ${aircraftDisplay}