diff --git a/backend/alembic/versions/005_flight_states.py b/backend/alembic/versions/005_flight_states.py index 6daa5a9..4dbcf98 100644 --- a/backend/alembic/versions/005_flight_states.py +++ b/backend/alembic/versions/005_flight_states.py @@ -25,7 +25,7 @@ def upgrade() -> None: 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')") + op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','LANDED','GROUND','LOCAL','CIRCUIT','ARRIVED','CANCELLED')") # Add timestamp column to arrivals op.add_column('arrivals', sa.Column('arrived_dt', sa.DateTime(), nullable=True)) @@ -36,9 +36,22 @@ def upgrade() -> None: # Add timestamp columns to departures op.add_column('departures', sa.Column('contact_dt', sa.DateTime(), nullable=True)) op.add_column('departures', sa.Column('takeoff_dt', sa.DateTime(), nullable=True)) + + # Add arrival_id column to circuits table to support circuit logging for arrivals + op.add_column('circuits', sa.Column('arrival_id', sa.BigInteger(), nullable=True)) + op.create_foreign_key('fk_circuits_arrival_id', 'circuits', 'arrivals', ['arrival_id'], ['id'], ondelete='CASCADE') + op.create_index('idx_circuit_arrival_id', 'circuits', ['arrival_id']) def downgrade() -> None: + # Remove arrival_id column from circuits table + op.drop_constraint('fk_circuits_arrival_id', 'circuits', type_='foreignkey') + op.drop_index('idx_circuit_arrival_id', table_name='circuits') + op.drop_column('circuits', 'arrival_id') + + # Update departures with new status values to valid old values before modifying enum + op.execute("UPDATE departures SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL')") + # Remove timestamp columns from departures op.drop_column('departures', 'takeoff_dt') op.drop_column('departures', 'contact_dt') @@ -46,12 +59,18 @@ def downgrade() -> None: # Remove GROUND and LOCAL from departures status enum op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','CANCELLED')") + # Update arrivals with new status values to valid old values before modifying enum + op.execute("UPDATE arrivals SET status = 'LANDED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT', 'ARRIVED')") + # Remove timestamp column from arrivals op.drop_column('arrivals', 'arrived_dt') # Remove GROUND and ARRIVED from arrivals status enum op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','LANDED','CANCELLED')") + # Update local_flights with new status values to valid old values before modifying enum + op.execute("UPDATE local_flights SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT')") + # Remove timestamp columns from local_flights op.drop_column('local_flights', 'takeoff_dt') op.drop_column('local_flights', 'contact_dt') diff --git a/backend/app/api/endpoints/circuits.py b/backend/app/api/endpoints/circuits.py index 697bfae..c3cc1d8 100644 --- a/backend/app/api/endpoints/circuits.py +++ b/backend/app/api/endpoints/circuits.py @@ -33,6 +33,17 @@ async def get_circuits_by_flight( return circuits +@router.get("/arrival/{arrival_id}", response_model=List[Circuit]) +async def get_circuits_by_arrival( + arrival_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get all circuits for a specific arrival""" + circuits = crud_circuit.get_by_arrival(db, arrival_id=arrival_id) + return circuits + + @router.post("/", response_model=Circuit) async def create_circuit( request: Request, @@ -40,7 +51,19 @@ async def create_circuit( db: Session = Depends(get_db), current_user: User = Depends(get_current_operator_user) ): - """Record a new circuit (touch and go) for a local flight""" + """Record a new circuit (touch and go) for a local flight or arrival""" + # Validate that exactly one of local_flight_id or arrival_id is provided + if not circuit_in.local_flight_id and not circuit_in.arrival_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either local_flight_id or arrival_id must be provided" + ) + if circuit_in.local_flight_id and circuit_in.arrival_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot provide both local_flight_id and arrival_id" + ) + circuit = crud_circuit.create(db, obj_in=circuit_in) # Send real-time update via WebSocket diff --git a/backend/app/crud/crud_circuit.py b/backend/app/crud/crud_circuit.py index 90bccf0..2f5879d 100644 --- a/backend/app/crud/crud_circuit.py +++ b/backend/app/crud/crud_circuit.py @@ -16,6 +16,12 @@ class CRUDCircuit: Circuit.local_flight_id == local_flight_id ).order_by(Circuit.circuit_timestamp).all() + def get_by_arrival(self, db: Session, arrival_id: int) -> List[Circuit]: + """Get all circuits for a specific arrival""" + return db.query(Circuit).filter( + Circuit.arrival_id == arrival_id + ).order_by(Circuit.circuit_timestamp).all() + def get_multi( self, db: Session, @@ -27,6 +33,7 @@ class CRUDCircuit: def create(self, db: Session, obj_in: CircuitCreate) -> Circuit: db_obj = Circuit( local_flight_id=obj_in.local_flight_id, + arrival_id=obj_in.arrival_id, circuit_timestamp=obj_in.circuit_timestamp ) db.add(db_obj) diff --git a/backend/app/main.py b/backend/app/main.py index 288d53d..c791562 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.models.journal import JournalEntry from app.models.local_flight import LocalFlight from app.models.departure import Departure from app.models.arrival import Arrival +from app.models.circuit import Circuit # Set up logging logging.basicConfig(level=logging.INFO) diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py index c56de1c..97aa645 100644 --- a/backend/app/models/arrival.py +++ b/backend/app/models/arrival.py @@ -1,9 +1,7 @@ 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() +from app.db.session import Base class SubmissionSource(str, Enum): @@ -15,6 +13,8 @@ class ArrivalStatus(str, Enum): BOOKED_IN = "BOOKED_IN" LANDED = "LANDED" GROUND = "GROUND" + LOCAL = "LOCAL" + CIRCUIT = "CIRCUIT" ARRIVED = "ARRIVED" CANCELLED = "CANCELLED" diff --git a/backend/app/models/circuit.py b/backend/app/models/circuit.py index c67fa20..9eb674f 100644 --- a/backend/app/models/circuit.py +++ b/backend/app/models/circuit.py @@ -7,6 +7,7 @@ class Circuit(Base): __tablename__ = "circuits" id = Column(BigInteger, primary_key=True, autoincrement=True) - local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True) + local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=True, index=True) + arrival_id = Column(BigInteger, ForeignKey("arrivals.id", ondelete="CASCADE"), nullable=True, index=True) circuit_timestamp = Column(DateTime, nullable=False, index=True) created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py index 7654c9d..f5ef6a7 100644 --- a/backend/app/schemas/arrival.py +++ b/backend/app/schemas/arrival.py @@ -8,6 +8,8 @@ class ArrivalStatus(str, Enum): BOOKED_IN = "BOOKED_IN" LANDED = "LANDED" GROUND = "GROUND" + LOCAL = "LOCAL" + CIRCUIT = "CIRCUIT" ARRIVED = "ARRIVED" CANCELLED = "CANCELLED" diff --git a/backend/app/schemas/circuit.py b/backend/app/schemas/circuit.py index 775059a..8a76031 100644 --- a/backend/app/schemas/circuit.py +++ b/backend/app/schemas/circuit.py @@ -4,7 +4,8 @@ from typing import Optional class CircuitBase(BaseModel): - local_flight_id: int + local_flight_id: Optional[int] = None + arrival_id: Optional[int] = None circuit_timestamp: datetime diff --git a/web/admin.html b/web/admin.html index f4d1add..c711383 100644 --- a/web/admin.html +++ b/web/admin.html @@ -1868,7 +1868,7 @@ try { // Load PPR departures, local flight departures, and airport departures simultaneously - const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ + const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse, arrLocalResponse, arrCircuitResponse] = 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=GROUND&limit=1000'), @@ -1876,7 +1876,9 @@ authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'), authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'), authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') + authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'), + authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'), + authenticatedFetch('/api/v1/arrivals/?status=CIRCUIT&limit=1000') ]); if (!pprResponse.ok) { @@ -1891,6 +1893,8 @@ const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : []; + const arrLocal = arrLocalResponse.ok ? await arrLocalResponse.json() : []; + const arrCircuit = arrCircuitResponse.ok ? await arrCircuitResponse.json() : []; // Combine local flights const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal, ...localCircuit]; @@ -1929,6 +1933,20 @@ })); departures.push(...depDepartures); + // Add arrivals in LOCAL status + const arrDepartures = arrLocal.map(flight => ({ + ...flight, + isArrival: true // Flag to distinguish from PPR + })); + departures.push(...arrDepartures); + + // Add arrivals in CIRCUIT status + const arrCircuitDepartures = arrCircuit.map(flight => ({ + ...flight, + isArrival: true // Flag to distinguish from PPR + })); + departures.push(...arrCircuitDepartures); + displayDepartures(departures); } catch (error) { console.error('Error loading departures:', error); @@ -2557,6 +2575,7 @@ const row = document.createElement('tr'); const isLocal = flight.isLocalFlight; const isDeparture = flight.isDeparture; + const isArrival = flight.isArrival; // Click handler that routes to correct modal row.onclick = () => { @@ -2564,6 +2583,8 @@ openLocalFlightEditModal(flight.id); } else if (isDeparture) { openDepartureEditModal(flight.id); + } else if (isArrival) { + openArrivalEditModal(flight.id); } else { openPPRModal(flight.id); } @@ -2682,6 +2703,42 @@ } else { actionButtons = '-'; } + } else if (isArrival) { + // Arrival display + if (flight.callsign && flight.callsign.trim()) { + aircraftDisplay = `${flight.callsign}
${flight.registration}`; + } else { + aircraftDisplay = `${flight.registration}`; + } + typeIcon = 'A'; + toDisplay = `Arrival from ${flight.in_from || '?'}`; + etd = flight.eta ? formatTimeOnly(flight.eta) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-'); + pob = flight.pob || '-'; + fuel = '-'; + landedDt = flight.arrived_dt ? formatTimeOnly(flight.arrived_dt) : '-'; + + // Action buttons for arrival + if (flight.status === 'LOCAL') { + actionButtons = ` + + `; + } else if (flight.status === 'CIRCUIT') { + actionButtons = ` + + + + `; + } else { + actionButtons = '-'; + } } else { // PPR display if (flight.ac_call && flight.ac_call.trim()) { @@ -3043,8 +3100,12 @@ } // Circuit modal functions - function showCircuitModal() { - if (!currentLocalFlightId) return; + function showCircuitModal(localFlightId = null, arrivalId = null) { + if (!localFlightId && !arrivalId) return; + + // Set the current IDs + currentLocalFlightId = localFlightId; + currentArrivalId = arrivalId; // Set default timestamp to current time const now = new Date(); @@ -3061,13 +3122,15 @@ function closeCircuitModal() { document.getElementById('circuitModal').style.display = 'none'; document.getElementById('circuit-form').reset(); + currentLocalFlightId = null; + currentArrivalId = null; } // Circuit form submission document.getElementById('circuit-form').addEventListener('submit', async function(e) { e.preventDefault(); - if (!currentLocalFlightId || !accessToken) return; + if ((!currentLocalFlightId && !currentArrivalId) || !accessToken) return; const circuitTimestampInput = document.getElementById('circuit-timestamp').value; if (!circuitTimestampInput) { @@ -3080,15 +3143,23 @@ const localDate = new Date(circuitTimestampInput); const circuitTimestamp = localDate.toISOString(); + const requestBody = { + circuit_timestamp: circuitTimestamp + }; + + // Add the appropriate ID based on what we're tracking + if (currentLocalFlightId) { + requestBody.local_flight_id = currentLocalFlightId; + } else if (currentArrivalId) { + requestBody.arrival_id = currentArrivalId; + } + const response = await authenticatedFetch('/api/v1/circuits/', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - local_flight_id: currentLocalFlightId, - circuit_timestamp: circuitTimestamp - }) + body: JSON.stringify(requestBody) }); if (!response.ok) { diff --git a/web/atc.html b/web/atc.html index 9a2b55c..bbedee0 100644 --- a/web/atc.html +++ b/web/atc.html @@ -3009,8 +3009,12 @@ } // Circuit modal functions - function showCircuitModal() { - if (!currentLocalFlightId) return; + function showCircuitModal(localFlightId = null, arrivalId = null) { + if (!localFlightId && !arrivalId) return; + + // Set the current IDs + currentLocalFlightId = localFlightId; + currentArrivalId = arrivalId; // Set default timestamp to current time const now = new Date(); @@ -3027,13 +3031,15 @@ function closeCircuitModal() { document.getElementById('circuitModal').style.display = 'none'; document.getElementById('circuit-form').reset(); + currentLocalFlightId = null; + currentArrivalId = null; } // Circuit form submission document.getElementById('circuit-form').addEventListener('submit', async function(e) { e.preventDefault(); - if (!currentLocalFlightId || !accessToken) return; + if ((!currentLocalFlightId && !currentArrivalId) || !accessToken) return; const circuitTimestampInput = document.getElementById('circuit-timestamp').value; if (!circuitTimestampInput) { @@ -3046,15 +3052,21 @@ const localDate = new Date(circuitTimestampInput); const circuitTimestamp = localDate.toISOString(); + const requestBody = { + circuit_timestamp: circuitTimestamp + }; + if (currentLocalFlightId) { + requestBody.local_flight_id = currentLocalFlightId; + } else if (currentArrivalId) { + requestBody.arrival_id = currentArrivalId; + } + const response = await authenticatedFetch('/api/v1/circuits/', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - local_flight_id: currentLocalFlightId, - circuit_timestamp: circuitTimestamp - }) + body: JSON.stringify(requestBody) }); if (!response.ok) { @@ -4602,41 +4614,10 @@ } } - // 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 table for booked-in arrivals + // Update status from table for arrivals async function updateArrivalStatusFromTable(arrivalId, status) { if (!accessToken) return; - // Show confirmation for cancel actions - if (status === 'CANCELLED') { - if (!confirm('Are you sure you want to cancel this arrival? This action cannot be easily undone.')) { - return; - } - } - try { const response = await fetch(`/api/v1/arrivals/${arrivalId}/status`, { method: 'PATCH', @@ -4649,7 +4630,7 @@ if (!response.ok) throw new Error('Failed to update status'); - loadArrivals(); // Refresh arrivals table + loadATCAircraft(); // Refresh display showNotification(`Arrival marked as ${status.toLowerCase()}`); } catch (error) { console.error('Error updating status:', error); @@ -5230,12 +5211,14 @@ try { const response = await Promise.all([ authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') + authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'), + authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000') ]); let locals = []; if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true })); if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true }))); + if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true }))); displayLocalAircraft(locals); } catch (error) { @@ -5264,9 +5247,9 @@ if (isDeparture) { // Departure in LOCAL status - show QSY button buttons = ``; - } else if (ac.isLocalFlight) { - // Local flight in LOCAL status - show REJOIN button - buttons = ``; + } else if (ac.isLocalFlight || ac.isArrival) { + // Local flight or arrival in LOCAL status - show REJOIN button + buttons = ``; } return ` @@ -5322,6 +5305,12 @@ const from = ac.in_from; const eta = ac.eta; + let buttons = ''; + if (ac.isArrival) { + // Arrival in BOOKED_IN status - show CONTACT button + buttons = ``; + } + return `
@@ -5329,7 +5318,7 @@
${type} from ${from || '?'}
${eta ? formatTimeOnly(eta) : ''}
-
IB
+ ${buttons ? `
${buttons}
` : '
IB
'}
`; }).join(''); @@ -5340,12 +5329,14 @@ try { const response = await Promise.all([ authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000&flight_type=CIRCUITS'), - authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000') + authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'), + authenticatedFetch('/api/v1/arrivals/?status=CIRCUIT&limit=1000') ]); let circuits = []; if (response[0].ok) circuits = await response[0].json(); if (response[1].ok) circuits = circuits.concat(await response[1].json()); + if (response[2].ok) circuits = circuits.concat((await response[2].json()).map(a => ({ ...a, isArrival: true }))); displayCircuitAircraft(circuits); } catch (error) { @@ -5364,20 +5355,37 @@ return; } - container.innerHTML = aircraft.map(ac => ` -
-
-
${ac.registration}
-
${ac.type}
-
${formatTimeOnly(ac.created_dt)}
+ container.innerHTML = aircraft.map(ac => { + const isArrival = ac.isArrival; + const entityType = isArrival ? 'arrival' : 'local'; + const updateFunction = isArrival ? 'updateArrivalStatusFromTable' : 'updateLocalFlightStatusFromTable'; + const landFunction = isArrival ? `${updateFunction}('${ac.id}', 'LANDED')` : `showTimestampModal('LANDED', ${ac.id}, true)`; + + let buttons = ` + + `; + + // Show T&G for both local flights and arrivals + const tgFunction = isArrival + ? `currentArrivalId = '${ac.id}'; showCircuitModal(null, '${ac.id}')` + : `currentLocalFlightId = '${ac.id}'; showCircuitModal('${ac.id}')`; + buttons += ``; + + buttons += ``; + + return ` +
+
+
${ac.registration}
+
${ac.type}
+
${formatTimeOnly(ac.created_dt)}
+
+
+ ${buttons} +
-
- - - -
-
- `).join(''); + `; + }).join(''); } // Load pending PPRs