local-flights #5

Merged
jamesp merged 37 commits from local-flights into main 2025-12-20 12:29:32 -05:00
16 changed files with 594 additions and 87 deletions
Showing only changes of commit a2682314c9 - Show all commits

View File

@@ -6,7 +6,8 @@ Create Date: 2025-12-12 12:00:00.000000
This migration adds a new table for tracking local flights (circuits, local, departure)
that don't require PPR submissions. Also adds etd and renames booked_out_dt to created_dt,
and departure_dt to departed_dt for consistency.
and departure_dt to departed_dt for consistency. Transforms journal table from PPR-specific
to a generic polymorphic journal for all entity types.
"""
from alembic import op
@@ -22,8 +23,41 @@ depends_on = None
def upgrade() -> None:
"""
Create local_flights, departures, and arrivals tables.
Transform journal table from PPR-specific to generic polymorphic journal.
"""
# Modify existing journal table to support all entity types
# First add new columns
op.add_column('journal', sa.Column('entity_type', sa.String(50), nullable=True))
op.add_column('journal', sa.Column('entity_id', sa.BigInteger(), nullable=True))
# Migrate existing PPR journal entries: backfill entity_type and entity_id
op.execute("""
UPDATE journal SET
entity_type = 'PPR',
entity_id = ppr_id
WHERE entity_type IS NULL
""")
# Make new columns NOT NULL after migration
op.alter_column('journal', 'entity_type', nullable=False)
op.alter_column('journal', 'entity_id', nullable=False)
# Make ip column nullable (new entries won't always have it)
op.alter_column('journal', 'ip', existing_type=sa.String(45), nullable=True)
# Drop the old ppr_id column
op.drop_column('journal', 'ppr_id')
# Add composite index for efficient queries
op.create_index('idx_entity_lookup', 'journal', ['entity_type', 'entity_id'])
# Drop old index if it exists
try:
op.drop_index('idx_ppr_id', table_name='journal')
except:
pass
op.create_table('local_flights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal
api_router = APIRouter()
@@ -9,6 +9,7 @@ api_router.include_router(local_flights.router, prefix="/local-flights", tags=["
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(public.router, prefix="/public", tags=["public"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])

View File

@@ -87,7 +87,16 @@ async def update_arrival(
detail="Arrival record not found"
)
arrival = crud_arrival.update(db, db_obj=db_arrival, obj_in=arrival_in)
# Get user IP from request
user_ip = request.client.host if request.client else None
arrival = crud_arrival.update(
db,
db_obj=db_arrival,
obj_in=arrival_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
@@ -112,11 +121,14 @@ async def update_arrival_status(
current_user: User = Depends(get_current_operator_user)
):
"""Update arrival status"""
client_ip = get_client_ip(request)
arrival = crud_arrival.update_status(
db,
arrival_id=arrival_id,
status=status_update.status,
timestamp=status_update.timestamp
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not arrival:
raise HTTPException(

View File

@@ -87,7 +87,16 @@ async def update_departure(
detail="Departure record not found"
)
departure = crud_departure.update(db, db_obj=db_departure, obj_in=departure_in)
# Get user IP from request
user_ip = request.client.host if request.client else None
departure = crud_departure.update(
db,
db_obj=db_departure,
obj_in=departure_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
@@ -112,11 +121,14 @@ async def update_departure_status(
current_user: User = Depends(get_current_operator_user)
):
"""Update departure status"""
client_ip = get_client_ip(request)
departure = crud_departure.update_status(
db,
departure_id=departure_id,
status=status_update.status,
timestamp=status_update.timestamp
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not departure:
raise HTTPException(

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api import deps
from app.crud.crud_journal import journal
from app.models.journal import EntityType
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
from typing import List
router = APIRouter(tags=["journal"])
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
async def get_entity_journal(
entity_type: str,
entity_id: int,
limit: int = 100,
db: Session = Depends(deps.get_db),
current_user = Depends(deps.get_current_user)
):
"""
Get journal entries for a specific entity (PPR, LOCAL_FLIGHT, ARRIVAL, or DEPARTURE).
The journal is immutable - entries are created automatically by the backend
when changes are made. This endpoint is read-only.
Parameters:
- entity_type: One of 'PPR', 'LOCAL_FLIGHT', 'ARRIVAL', 'DEPARTURE'
- entity_id: The ID of the entity
- limit: Maximum number of entries to return (default 100)
"""
# Validate entity type
try:
entity = EntityType[entity_type.upper()]
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}"
)
entries = journal.get_entity_journal(db, entity, entity_id, limit=limit)
return EntityJournalResponse(
entity_type=entity_type,
entity_id=entity_id,
entries=entries,
total_entries=len(entries)
)
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
async def get_user_journal(
username: str,
limit: int = 100,
db: Session = Depends(deps.get_db),
current_user = Depends(deps.get_current_user)
):
"""
Get all journal entries created by a specific user.
This endpoint is read-only and returns entries in reverse chronological order.
"""
entries = journal.get_user_journal(db, username, limit=limit)
return entries

View File

@@ -88,7 +88,16 @@ async def update_local_flight(
detail="Local flight record not found"
)
flight = crud_local_flight.update(db, db_obj=db_flight, obj_in=flight_in)
# Get user IP from request
user_ip = request.client.host if request.client else None
flight = crud_local_flight.update(
db,
db_obj=db_flight,
obj_in=flight_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
@@ -113,11 +122,14 @@ async def update_local_flight_status(
current_user: User = Depends(get_current_operator_user)
):
"""Update local flight status (LANDED, CANCELLED, etc.)"""
client_ip = get_client_ip(request)
flight = crud_local_flight.update_status(
db,
flight_id=flight_id,
status=status_update.status,
timestamp=status_update.timestamp
timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
)
if not flight:
raise HTTPException(

View File

@@ -4,6 +4,8 @@ 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
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDArrival:
@@ -56,16 +58,43 @@ class CRUDArrival:
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate) -> Arrival:
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
if value is not None:
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.ARRIVAL,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
@@ -73,12 +102,15 @@ class CRUDArrival:
db: Session,
arrival_id: int,
status: ArrivalStatus,
timestamp: Optional[datetime] = None
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp:
@@ -87,6 +119,17 @@ class CRUDArrival:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.ARRIVAL,
arrival_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:

View File

@@ -4,6 +4,8 @@ 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
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDDeparture:
@@ -56,16 +58,43 @@ class CRUDDeparture:
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate) -> Departure:
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
if value is not None:
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.DEPARTURE,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
@@ -73,12 +102,15 @@ class CRUDDeparture:
db: Session,
departure_id: int,
status: DepartureStatus,
timestamp: Optional[datetime] = None
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp:
@@ -87,6 +119,17 @@ class CRUDDeparture:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.DEPARTURE,
departure_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:

View File

@@ -1,35 +1,95 @@
from typing import List
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.ppr import Journal
from app.schemas.ppr import JournalCreate
from app.models.journal import JournalEntry, EntityType
from datetime import datetime
class CRUDJournal:
def create(self, db: Session, obj_in: JournalCreate) -> Journal:
db_obj = Journal(**obj_in.dict())
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
"""CRUD operations for the generic journal table.
def get_by_ppr_id(self, db: Session, ppr_id: int) -> List[Journal]:
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
This journal is immutable - entries can only be created (by backend) and queried.
There are no API endpoints for creating journal entries; the backend logs changes directly.
"""
def log_change(
self,
db: Session,
entity_type: EntityType,
entity_id: int,
entry: str,
user: str,
ip: Optional[str] = None
) -> JournalEntry:
"""Log a change to an entity. Internal backend use only."""
journal_entry = JournalEntry(
entity_type=entity_type.value,
entity_id=entity_id,
entry=entry,
user=user,
ip=ip,
entry_dt=datetime.utcnow()
)
db.add(journal_entry)
db.commit()
db.refresh(journal_entry)
return journal_entry
def get_entity_journal(
self,
db: Session,
entity_type: EntityType,
entity_id: int,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries for a specific entity. Read-only API endpoint."""
return db.query(JournalEntry).filter(
JournalEntry.entity_type == entity_type.value,
JournalEntry.entity_id == entity_id
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
def get_user_journal(
self,
db: Session,
user: str,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries created by a specific user."""
return db.query(JournalEntry).filter(
JournalEntry.user == user
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
# Convenience methods for backward compatibility with PPR journal
def log_ppr_change(
self,
db: Session,
ppr_id: int,
entry: str,
user: str,
ip: str
) -> Journal:
journal_in = JournalCreate(
ppr_id=ppr_id,
ip: Optional[str] = None
) -> JournalEntry:
"""Log a change to a PPR (convenience method)."""
return self.log_change(
db=db,
entity_type=EntityType.PPR,
entity_id=ppr_id,
entry=entry,
user=user,
ip=ip
)
return self.create(db, journal_in)
def get_ppr_journal(
self,
db: Session,
ppr_id: int,
limit: int = 100
) -> List[JournalEntry]:
"""Get all journal entries for a PPR (convenience method)."""
return self.get_entity_journal(
db=db,
entity_type=EntityType.PPR,
entity_id=ppr_id,
limit=limit
)
journal = CRUDJournal()

View File

@@ -4,6 +4,8 @@ from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDLocalFlight:
@@ -82,16 +84,43 @@ class CRUDLocalFlight:
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate) -> LocalFlight:
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
if value is not None:
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
@@ -99,7 +128,9 @@ class CRUDLocalFlight:
db: Session,
flight_id: int,
status: LocalFlightStatus,
timestamp: Optional[datetime] = None
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[LocalFlight]:
db_obj = self.get(db, flight_id)
if not db_obj:
@@ -109,6 +140,7 @@ class CRUDLocalFlight:
if isinstance(status, str):
status = LocalFlightStatus(status)
old_status = db_obj.status
db_obj.status = status
# Set timestamps based on status
@@ -121,6 +153,17 @@ class CRUDLocalFlight:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
flight_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:

View File

@@ -98,11 +98,22 @@ class CRUDPPR:
return db_obj
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
# Compare only the date and time, ignoring timezone
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue # Skip if datetimes are the same
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
@@ -114,7 +125,7 @@ class CRUDPPR:
# Log changes in journal
for change in changes:
crud_journal.log_change(db, db_obj.id, change, user, user_ip)
crud_journal.log_ppr_change(db, db_obj.id, change, user, user_ip)
return db_obj
@@ -146,7 +157,7 @@ class CRUDPPR:
db.refresh(db_obj)
# Log status change in journal
crud_journal.log_change(
crud_journal.log_ppr_change(
db,
db_obj.id,
f"Status changed from {old_status.value} to {status.value}",

View File

@@ -9,7 +9,8 @@ from app.core.config import settings
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.ppr import PPRRecord, User, Airport, Aircraft
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

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, BigInteger, String, Text, DateTime, Index, func
from datetime import datetime
from enum import Enum as PyEnum
from app.db.session import Base
class EntityType(str, PyEnum):
"""Entity types that can have journal entries"""
PPR = "PPR"
LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
class JournalEntry(Base):
"""
Generic journal table for tracking changes across all entity types.
Replaces the PPR-specific journal table.
"""
__tablename__ = "journal"
id = Column(BigInteger, primary_key=True, autoincrement=True)
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the entity
entry = Column(Text, nullable=False)
user = Column(String(50), nullable=False, index=True)
ip = Column(String(45), nullable=True) # Made optional for new entries
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
# Composite index for efficient queries
__table_args__ = (
Index('idx_entity_lookup', 'entity_type', 'entity_id'),
)

View File

@@ -60,17 +60,6 @@ class User(Base):
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
class Journal(Base):
__tablename__ = "journal"
id = Column(BigInteger, primary_key=True, autoincrement=True)
ppr_id = Column(BigInteger, nullable=False, index=True) # Changed to BigInteger to match submitted.id
entry = Column(Text, nullable=False)
user = Column(String(50), nullable=False, index=True)
ip = Column(String(45), nullable=False)
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
class Airport(Base):
__tablename__ = "airports"

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class JournalEntryResponse(BaseModel):
"""Read-only schema for journal entries"""
id: int
entity_type: str # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
entity_id: int
entry: str
user: str
ip: Optional[str]
entry_dt: datetime
class Config:
from_attributes = True
class EntityJournalResponse(BaseModel):
"""Response containing all journal entries for an entity"""
entity_type: str
entity_id: int
entries: list[JournalEntryResponse]
total_entries: int
class Config:
from_attributes = True

View File

@@ -528,6 +528,14 @@
<p style="color: #666; font-style: italic;">Loading circuits...</p>
</div>
</div>
<!-- Journal Section -->
<div id="local-flight-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="local-flight-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
@@ -650,6 +658,14 @@
</button>
</div>
</form>
<!-- Journal Section -->
<div id="departure-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="departure-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
@@ -710,6 +726,14 @@
</button>
</div>
</form>
<!-- Journal Section -->
<div id="arrival-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="arrival-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
@@ -1604,15 +1628,27 @@
function displayDeparted(departed) {
const tbody = document.getElementById('departed-table-body');
document.getElementById('departed-count').textContent = departed.length;
if (departed.length === 0) {
// Deduplicate departed flights by ID to prevent duplicates from race conditions
const seenIds = new Set();
const uniqueDeparted = departed.filter(flight => {
if (seenIds.has(flight.id)) {
console.warn(`Duplicate departed flight detected and filtered: ID ${flight.id}`);
return false;
}
seenIds.add(flight.id);
return true;
});
document.getElementById('departed-count').textContent = uniqueDeparted.length;
if (uniqueDeparted.length === 0) {
document.getElementById('departed-no-data').style.display = 'block';
return;
}
// Sort by departed time
departed.sort((a, b) => {
uniqueDeparted.sort((a, b) => {
const aTime = a.departed_dt;
const bTime = b.departed_dt;
return new Date(aTime) - new Date(bTime);
@@ -1621,7 +1657,7 @@
tbody.innerHTML = '';
document.getElementById('departed-table-content').style.display = 'block';
for (const flight of departed) {
for (const flight of uniqueDeparted) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
@@ -1726,15 +1762,27 @@
function displayParked(parked) {
const tbody = document.getElementById('parked-table-body');
document.getElementById('parked-count').textContent = parked.length;
if (parked.length === 0) {
// Deduplicate parked flights by ID to prevent duplicates from race conditions
const seenIds = new Set();
const uniqueParked = parked.filter(flight => {
if (seenIds.has(flight.id)) {
console.warn(`Duplicate parked flight detected and filtered: ID ${flight.id}`);
return false;
}
seenIds.add(flight.id);
return true;
});
document.getElementById('parked-count').textContent = uniqueParked.length;
if (uniqueParked.length === 0) {
document.getElementById('parked-no-data').style.display = 'block';
return;
}
// Sort by landed time
parked.sort((a, b) => {
uniqueParked.sort((a, b) => {
if (!a.landed_dt) return 1;
if (!b.landed_dt) return -1;
return new Date(a.landed_dt) - new Date(b.landed_dt);
@@ -1743,7 +1791,7 @@
tbody.innerHTML = '';
document.getElementById('parked-table-content').style.display = 'block';
for (const ppr of parked) {
for (const ppr of uniqueParked) {
const row = document.createElement('tr');
const isBookedIn = ppr.isBookedIn;
@@ -1841,20 +1889,32 @@
function displayUpcoming(upcoming) {
const tbody = document.getElementById('upcoming-table-body');
document.getElementById('upcoming-count').textContent = upcoming.length;
if (upcoming.length === 0) {
// Deduplicate upcoming flights by ID to prevent duplicates from race conditions
const seenIds = new Set();
const uniqueUpcoming = upcoming.filter(ppr => {
if (seenIds.has(ppr.id)) {
console.warn(`Duplicate upcoming flight detected and filtered: ID ${ppr.id}`);
return false;
}
seenIds.add(ppr.id);
return true;
});
document.getElementById('upcoming-count').textContent = uniqueUpcoming.length;
if (uniqueUpcoming.length === 0) {
// Don't show anything if collapsed by default
return;
}
// Sort by ETA date and time
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
uniqueUpcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
tbody.innerHTML = '';
// Don't auto-expand, keep collapsed by default
for (const ppr of upcoming) {
for (const ppr of uniqueUpcoming) {
const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important;';
@@ -1932,14 +1992,26 @@
async function displayArrivals(arrivals) {
const tbody = document.getElementById('arrivals-table-body');
const recordCount = document.getElementById('arrivals-count');
recordCount.textContent = arrivals.length;
if (arrivals.length === 0) {
// Deduplicate arrivals by ID to prevent duplicates from race conditions
const seenIds = new Set();
const uniqueArrivals = arrivals.filter(flight => {
if (seenIds.has(flight.id)) {
console.warn(`Duplicate arrival detected and filtered: ID ${flight.id}`);
return false;
}
seenIds.add(flight.id);
return true;
});
recordCount.textContent = uniqueArrivals.length;
if (uniqueArrivals.length === 0) {
document.getElementById('arrivals-no-data').style.display = 'block';
return;
}
// Sort arrivals by ETA/departure time (ascending)
arrivals.sort((a, b) => {
uniqueArrivals.sort((a, b) => {
const aTime = a.eta || a.departure_dt;
const bTime = b.eta || b.departure_dt;
if (!aTime) return 1;
@@ -1948,7 +2020,7 @@
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
for (const flight of arrivals) {
for (const flight of uniqueArrivals) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isBookedIn = flight.isBookedIn;
@@ -2091,14 +2163,26 @@
async function displayDepartures(departures) {
const tbody = document.getElementById('departures-table-body');
const recordCount = document.getElementById('departures-count');
recordCount.textContent = departures.length;
if (departures.length === 0) {
// Deduplicate departures by ID to prevent duplicates from race conditions
const seenIds = new Set();
const uniqueDepartures = departures.filter(flight => {
if (seenIds.has(flight.id)) {
console.warn(`Duplicate flight detected and filtered: ID ${flight.id}`);
return false;
}
seenIds.add(flight.id);
return true;
});
recordCount.textContent = uniqueDepartures.length;
if (uniqueDepartures.length === 0) {
document.getElementById('departures-no-data').style.display = 'block';
return;
}
// Sort departures by ETD (ascending), nulls last
departures.sort((a, b) => {
uniqueDepartures.sort((a, b) => {
const aTime = a.etd || a.created_dt;
const bTime = b.etd || b.created_dt;
if (!aTime) return 1;
@@ -2107,7 +2191,7 @@
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
for (const flight of departures) {
for (const flight of uniqueDepartures) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
@@ -2461,9 +2545,10 @@
});
}
async function loadJournal(pprId) {
// Generic function to load journal for any entity type
async function loadJournalForEntity(entityType, entityId, containerElementId) {
try {
const response = await fetch(`/api/v1/pprs/${pprId}/journal`, {
const response = await fetch(`/api/v1/journal/${entityType}/${entityId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
@@ -2473,16 +2558,40 @@
throw new Error('Failed to fetch journal');
}
const entries = await response.json();
displayJournal(entries);
const data = await response.json();
// The new API returns { entity_type, entity_id, entries, total_entries }
displayJournalForContainer(data.entries || [], containerElementId);
} catch (error) {
console.error('Error loading journal:', error);
document.getElementById('journal-entries').innerHTML = 'Error loading journal entries';
const container = document.getElementById(containerElementId);
if (container) container.innerHTML = 'Error loading journal entries';
}
}
function displayJournal(entries) {
const container = document.getElementById('journal-entries');
// PPR-specific journal loader (backward compatible)
async function loadJournal(pprId) {
await loadJournalForEntity('PPR', pprId, 'journal-entries');
}
// Local Flight specific journal loader
async function loadLocalFlightJournal(flightId) {
await loadJournalForEntity('LOCAL_FLIGHT', flightId, 'local-flight-journal-entries');
}
// Departure specific journal loader
async function loadDepartureJournal(departureId) {
await loadJournalForEntity('DEPARTURE', departureId, 'departure-journal-entries');
}
// Arrival specific journal loader
async function loadArrivalJournal(arrivalId) {
await loadJournalForEntity('ARRIVAL', arrivalId, 'arrival-journal-entries');
}
// Display journal in a specific container
function displayJournalForContainer(entries, containerElementId) {
const container = document.getElementById(containerElementId);
if (!container) return;
if (entries.length === 0) {
container.innerHTML = '<p>No journal entries yet.</p>';
@@ -2496,6 +2605,10 @@
</div>
`).join('');
}
}
function displayJournal(entries) {
displayJournalForContainer(entries, 'journal-entries');
// Always show journal section when displaying entries
document.getElementById('journal-section').style.display = 'block';
@@ -3390,6 +3503,9 @@
}
document.getElementById('localFlightEditModal').style.display = 'block';
// Load journal for this local flight
await loadLocalFlightJournal(flightId);
} catch (error) {
console.error('Error loading flight:', error);
showNotification('Error loading flight details', true);
@@ -3472,6 +3588,9 @@
document.getElementById('departure-edit-title').textContent = `${departure.registration} to ${departure.out_to}`;
document.getElementById('departureEditModal').style.display = 'block';
// Load journal for this departure
await loadDepartureJournal(departureId);
} catch (error) {
console.error('Error loading departure:', error);
showNotification('Error loading departure details', true);
@@ -3573,6 +3692,9 @@
// Show modal
document.getElementById('arrivalEditModal').style.display = 'block';
// Load journal for this arrival
await loadArrivalJournal(arrivalId);
} catch (error) {
console.error('Error loading arrival:', error);
showNotification('Error loading arrival details', true);