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 `