diff --git a/FLIGHT_STATE_FLOWS.md b/FLIGHT_STATE_FLOWS.md deleted file mode 100644 index 7eeb7a7..0000000 --- a/FLIGHT_STATE_FLOWS.md +++ /dev/null @@ -1,186 +0,0 @@ -# Flight State Flow Documentation - -This document describes the state flows for each flight type in the PPR system. - -## Overview - -The system manages different types of aircraft operations, each with their own state machines and status transitions. The main flight types are: - -1. **Circuits** - Touch-and-go circuit training flights -2. **Local Flights** - General local operations -3. **Inbound/Arrivals** - Aircraft arriving at the airport -4. **Departures** - Aircraft departing to other airports -5. **PPR Records** - Prior permission required advance bookings -6. **Overflights** - Aircraft passing through airspace - -## 1. Circuits - -Circuits are a special type of local flight where aircraft perform touch-and-go operations. The circuit system tracks individual circuit completions. - -### State Flow -``` -BOOKED_OUT → DEPARTED → LANDED - ↓ ↓ ↓ - CANCELLED CANCELLED CANCELLED -``` - -### Status Descriptions -- **BOOKED_OUT**: Flight is booked out and ready for departure -- **DEPARTED**: Aircraft has departed the airport -- **LANDED**: Aircraft has landed and flight is complete -- **CANCELLED**: Flight was cancelled before completion - -### Additional Features -- Individual circuit timestamps are recorded in the `circuits` table -- The `circuits` field on the local_flight record stores the total number of completed circuits -- Circuits are counted automatically when the flight status changes to LANDED - -## 2. Local Flights - -Local flights represent general aviation operations within the local area. - -### State Flow -``` -BOOKED_OUT → DEPARTED → LANDED - ↓ ↓ ↓ - CANCELLED CANCELLED CANCELLED -``` - -### Status Descriptions -- **BOOKED_OUT**: Flight is booked out and ready for departure -- **DEPARTED**: Aircraft has departed the airport -- **LANDED**: Aircraft has landed and flight is complete -- **CANCELLED**: Flight was cancelled before completion - -### Flight Types -Local flights can be categorized by their `flight_type`: -- **LOCAL**: Standard local flight -- **CIRCUITS**: Circuit training flight (see Circuits section above) -- **DEPARTURE**: Flight departing to another airport (may transition to Departure records) - -## 3. Inbound/Arrivals - -Inbound flights are aircraft arriving at the airport, either through the PPR system or direct bookings. - -### State Flow -``` -BOOKED_IN → LANDED - ↓ ↓ - CANCELLED CANCELLED -``` - -### Status Descriptions -- **BOOKED_IN**: Aircraft is expected to arrive (booked in the system) -- **LANDED**: Aircraft has landed at the airport -- **CANCELLED**: Arrival was cancelled - -### Notes -- Arrivals can originate from PPR records that have landed -- Direct arrival bookings are also supported -- Landing timestamp is recorded when status changes to LANDED - -## 4. Departures - -Departures are aircraft leaving the airport to fly to other destinations. - -### State Flow -``` -BOOKED_OUT → DEPARTED - ↓ ↓ - CANCELLED CANCELLED -``` - -### Status Descriptions -- **BOOKED_OUT**: Flight is booked out and ready for departure -- **DEPARTED**: Aircraft has departed to its destination -- **CANCELLED**: Departure was cancelled - -### Notes -- Departures can originate from PPR records that are departing -- Local flights with `flight_type = DEPARTURE` may transition to departure records -- Departure timestamp is recorded when status changes to DEPARTED - -## 5. PPR (Prior Permission Required) Records - -PPR records represent advance permission requests for aircraft operations and have a more complex lifecycle. - -### State Flow -``` - NEW → CONFIRMED → LANDED → DEPARTED - ↓ ↓ ↓ ↓ - CANCELED CANCELED CANCELED (terminal) - ↓ - DELETED -``` - -### Status Descriptions -- **NEW**: PPR has been submitted but not yet confirmed -- **CONFIRMED**: PPR has been confirmed by ATC -- **LANDED**: Aircraft has landed (for inbound operations) -- **DEPARTED**: Aircraft has departed (for outbound operations) -- **CANCELED**: PPR was cancelled -- **DELETED**: PPR was soft-deleted (marked as deleted) - -### Notes -- PPRs can represent both arrivals and departures -- The system tracks both ETA (Estimated Time of Arrival) and ETD (Estimated Time of Departure) -- Timestamps are recorded for actual landing and departure times -- PPRs can transition between arrival and departure operations - -## 6. Overflights - -Overflights represent aircraft passing through the airspace without landing at the airport. - -### State Flow -``` -ACTIVE → INACTIVE - ↓ ↓ -CANCELLED CANCELLED -``` - -### Status Descriptions -- **ACTIVE**: Overflight is currently active/tracking -- **INACTIVE**: Overflight has completed or is no longer active -- **CANCELLED**: Overflight was cancelled - -### Notes -- Overflights track aircraft that call in for frequency changes (QSY) -- Call time and QSY time are recorded -- Overflights are typically managed separately from landing operations - -## Status Transition Rules - -### Automatic Transitions -- Status changes automatically set appropriate timestamps: - - `DEPARTED`: Sets `departed_dt` - - `LANDED`: Sets `landed_dt` - - For circuits: Also counts completed circuits - -### Manual Transitions -- Operators can manually update statuses through the admin interface -- Status changes are logged in the journal/audit trail -- WebSocket notifications are sent for real-time updates - -### Validation Rules -- Cannot transition backwards in the flow (e.g., from LANDED to DEPARTED) -- CANCELLED/CANCELED are terminal states for most flight types -- DELETED is only used for PPR soft-deletion -- Status enum values: Most use "CANCELLED", PPR uses "CANCELED" - -## Integration Points - -### UI Integration -- Admin interface provides buttons for status updates -- Real-time updates via WebSocket -- Modal dialogs for status changes with timestamp confirmation - -### API Integration -- REST endpoints for status updates: `PATCH /{id}/status` -- Status filtering available on list endpoints -- Journal logging for all status changes - -### Database Integration -- Status enums ensure data integrity -- Foreign key relationships maintain consistency -- Audit trail tracks all changes -/home/jamesp/docker/pprdev/FLIGHT_STATE_FLOWS.md \ No newline at end of file diff --git a/backend/alembic/versions/005_flight_states.py b/backend/alembic/versions/005_flight_states.py new file mode 100644 index 0000000..6daa5a9 --- /dev/null +++ b/backend/alembic/versions/005_flight_states.py @@ -0,0 +1,60 @@ +"""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','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','LANDED','GROUND','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','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)) + + +def downgrade() -> None: + # 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')") + + # 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')") + + # 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')") diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py index f5d8a2a..108df75 100644 --- a/backend/app/api/endpoints/departures.py +++ b/backend/app/api/endpoints/departures.py @@ -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'): diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index 2877eed..1a6e2c3 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -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 }) diff --git a/backend/app/api/endpoints/public_book.py b/backend/app/api/endpoints/public_book.py index 39794f4..e4c2f03 100644 --- a/backend/app/api/endpoints/public_book.py +++ b/backend/app/api/endpoints/public_book.py @@ -18,7 +18,7 @@ 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 SubmissionSource as DepartureSubmissionSource +from app.models.departure import DepartureStatus from app.models.arrival import SubmissionSource as ArrivalSubmissionSource router = APIRouter() @@ -136,11 +136,10 @@ async def public_book_departure( notes=departure_in.notes, ) - departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT") + departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC") - # Update with submission source and pilot email + # 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).submitted_via: DepartureSubmissionSource.PUBLIC, type(departure).pilot_email: departure_in.pilot_email, }) db.commit() diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py index 1e5e9dc..1599770 100644 --- a/backend/app/crud/crud_arrival.py +++ b/backend/app/crud/crud_arrival.py @@ -113,8 +113,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() diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py index a6fd2c1..50409d0 100644 --- a/backend/app/crud/crud_departure.py +++ b/backend/app/crud/crud_departure.py @@ -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() diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py index 621923c..6a5decf 100644 --- a/backend/app/crud/crud_local_flight.py +++ b/backend/app/crud/crud_local_flight.py @@ -144,10 +144,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 diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py index d9fe216..c56de1c 100644 --- a/backend/app/models/arrival.py +++ b/backend/app/models/arrival.py @@ -14,6 +14,8 @@ class SubmissionSource(str, Enum): class ArrivalStatus(str, Enum): BOOKED_IN = "BOOKED_IN" LANDED = "LANDED" + GROUND = "GROUND" + ARRIVED = "ARRIVED" CANCELLED = "CANCELLED" @@ -31,6 +33,7 @@ class Arrival(Base): 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 diff --git a/backend/app/models/departure.py b/backend/app/models/departure.py index a1d0a98..e4ba37f 100644 --- a/backend/app/models/departure.py +++ b/backend/app/models/departure.py @@ -13,7 +13,9 @@ class SubmissionSource(str, Enum): class DepartureStatus(str, Enum): BOOKED_OUT = "BOOKED_OUT" + GROUND = "GROUND" DEPARTED = "DEPARTED" + LOCAL = "LOCAL" CANCELLED = "CANCELLED" @@ -30,7 +32,9 @@ 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 diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py index 325c5c6..ec03ffc 100644 --- a/backend/app/models/local_flight.py +++ b/backend/app/models/local_flight.py @@ -17,7 +17,10 @@ class LocalFlightType(str, Enum): class LocalFlightStatus(str, Enum): BOOKED_OUT = "BOOKED_OUT" + GROUND = "GROUND" DEPARTED = "DEPARTED" + LOCAL = "LOCAL" + CIRCUIT = "CIRCUIT" LANDED = "LANDED" CANCELLED = "CANCELLED" @@ -37,7 +40,9 @@ 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) diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py index 063bcf2..7654c9d 100644 --- a/backend/app/schemas/arrival.py +++ b/backend/app/schemas/arrival.py @@ -7,6 +7,8 @@ from enum import Enum class ArrivalStatus(str, Enum): BOOKED_IN = "BOOKED_IN" LANDED = "LANDED" + GROUND = "GROUND" + ARRIVED = "ARRIVED" CANCELLED = "CANCELLED" @@ -52,6 +54,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 @@ -66,6 +72,7 @@ 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 diff --git a/backend/app/schemas/departure.py b/backend/app/schemas/departure.py index 28ef6fd..9365d20 100644 --- a/backend/app/schemas/departure.py +++ b/backend/app/schemas/departure.py @@ -6,7 +6,9 @@ from enum import Enum class DepartureStatus(str, Enum): BOOKED_OUT = "BOOKED_OUT" + GROUND = "GROUND" DEPARTED = "DEPARTED" + LOCAL = "LOCAL" CANCELLED = "CANCELLED" @@ -53,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 @@ -67,7 +73,9 @@ 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 diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py index 855c8bc..38e93f1 100644 --- a/backend/app/schemas/local_flight.py +++ b/backend/app/schemas/local_flight.py @@ -12,7 +12,10 @@ class LocalFlightType(str, Enum): class LocalFlightStatus(str, Enum): BOOKED_OUT = "BOOKED_OUT" + GROUND = "GROUND" DEPARTED = "DEPARTED" + LOCAL = "LOCAL" + CIRCUIT = "CIRCUIT" LANDED = "LANDED" CANCELLED = "CANCELLED" @@ -66,7 +69,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 @@ -81,7 +86,9 @@ 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 diff --git a/web/admin.html b/web/admin.html index e284f49..f4d1add 100644 --- a/web/admin.html +++ b/web/admin.html @@ -29,6 +29,7 @@ ⚙️ Admin