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) 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, 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 from alembic import op
@@ -22,8 +23,41 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
""" """
Create local_flights, departures, and arrivals tables. 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', op.create_table('local_flights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False), sa.Column('registration', sa.String(length=16), nullable=False),

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter 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() 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(departures.router, prefix="/departures", tags=["departures"])
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"]) api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"]) 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(public.router, prefix="/public", tags=["public"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"]) api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"]) 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" 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 # Send real-time update
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
@@ -112,11 +121,14 @@ async def update_arrival_status(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Update arrival status""" """Update arrival status"""
client_ip = get_client_ip(request)
arrival = crud_arrival.update_status( arrival = crud_arrival.update_status(
db, db,
arrival_id=arrival_id, arrival_id=arrival_id,
status=status_update.status, status=status_update.status,
timestamp=status_update.timestamp timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
) )
if not arrival: if not arrival:
raise HTTPException( raise HTTPException(

View File

@@ -87,7 +87,16 @@ async def update_departure(
detail="Departure record not found" 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 # Send real-time update
if hasattr(request.app.state, 'connection_manager'): if hasattr(request.app.state, 'connection_manager'):
@@ -112,11 +121,14 @@ async def update_departure_status(
current_user: User = Depends(get_current_operator_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Update departure status""" """Update departure status"""
client_ip = get_client_ip(request)
departure = crud_departure.update_status( departure = crud_departure.update_status(
db, db,
departure_id=departure_id, departure_id=departure_id,
status=status_update.status, status=status_update.status,
timestamp=status_update.timestamp timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
) )
if not departure: if not departure:
raise HTTPException( 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" 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 # Send real-time update
if hasattr(request.app.state, 'connection_manager'): 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) current_user: User = Depends(get_current_operator_user)
): ):
"""Update local flight status (LANDED, CANCELLED, etc.)""" """Update local flight status (LANDED, CANCELLED, etc.)"""
client_ip = get_client_ip(request)
flight = crud_local_flight.update_status( flight = crud_local_flight.update_status(
db, db,
flight_id=flight_id, flight_id=flight_id,
status=status_update.status, status=status_update.status,
timestamp=status_update.timestamp timestamp=status_update.timestamp,
user=current_user.username,
user_ip=client_ip
) )
if not flight: if not flight:
raise HTTPException( raise HTTPException(

View File

@@ -4,6 +4,8 @@ from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime from datetime import date, datetime
from app.models.arrival import Arrival, ArrivalStatus from app.models.arrival import Arrival, ArrivalStatus
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDArrival: class CRUDArrival:
@@ -56,16 +58,43 @@ class CRUDArrival:
db.refresh(db_obj) db.refresh(db_obj)
return 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) update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items(): 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) setattr(db_obj, field, value)
db.add(db_obj) if changes:
db.commit() db.add(db_obj)
db.refresh(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 return db_obj
def update_status( def update_status(
@@ -73,12 +102,15 @@ class CRUDArrival:
db: Session, db: Session,
arrival_id: int, arrival_id: int,
status: ArrivalStatus, status: ArrivalStatus,
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Arrival]: ) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id) db_obj = self.get(db, arrival_id)
if not db_obj: if not db_obj:
return None return None
old_status = db_obj.status
db_obj.status = status db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp: if status == ArrivalStatus.LANDED and timestamp:
@@ -87,6 +119,17 @@ class CRUDArrival:
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]: 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 datetime import date, datetime
from app.models.departure import Departure, DepartureStatus from app.models.departure import Departure, DepartureStatus
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDDeparture: class CRUDDeparture:
@@ -56,16 +58,43 @@ class CRUDDeparture:
db.refresh(db_obj) db.refresh(db_obj)
return 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) update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items(): 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) setattr(db_obj, field, value)
db.add(db_obj) if changes:
db.commit() db.add(db_obj)
db.refresh(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 return db_obj
def update_status( def update_status(
@@ -73,12 +102,15 @@ class CRUDDeparture:
db: Session, db: Session,
departure_id: int, departure_id: int,
status: DepartureStatus, status: DepartureStatus,
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Departure]: ) -> Optional[Departure]:
db_obj = self.get(db, departure_id) db_obj = self.get(db, departure_id)
if not db_obj: if not db_obj:
return None return None
old_status = db_obj.status
db_obj.status = status db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp: if status == DepartureStatus.DEPARTED and timestamp:
@@ -87,6 +119,17 @@ class CRUDDeparture:
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]: 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 sqlalchemy.orm import Session
from app.models.ppr import Journal from app.models.journal import JournalEntry, EntityType
from app.schemas.ppr import JournalCreate from datetime import datetime
class CRUDJournal: class CRUDJournal:
def create(self, db: Session, obj_in: JournalCreate) -> Journal: """CRUD operations for the generic journal table.
db_obj = Journal(**obj_in.dict())
db.add(db_obj) This journal is immutable - entries can only be created (by backend) and queried.
db.commit() There are no API endpoints for creating journal entries; the backend logs changes directly.
db.refresh(db_obj) """
return db_obj
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()
def log_change( def log_change(
self, self,
db: Session, db: Session,
ppr_id: int, entity_type: EntityType,
entity_id: int,
entry: str, entry: str,
user: str, user: str,
ip: str ip: Optional[str] = None
) -> Journal: ) -> JournalEntry:
journal_in = JournalCreate( """Log a change to an entity. Internal backend use only."""
ppr_id=ppr_id, 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: 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, entry=entry,
user=user, user=user,
ip=ip 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() journal = CRUDJournal()

View File

@@ -4,6 +4,8 @@ from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime from datetime import date, datetime
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDLocalFlight: class CRUDLocalFlight:
@@ -82,16 +84,43 @@ class CRUDLocalFlight:
db.refresh(db_obj) db.refresh(db_obj)
return 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) update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items(): 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) setattr(db_obj, field, value)
db.add(db_obj) if changes:
db.commit() db.add(db_obj)
db.refresh(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 return db_obj
def update_status( def update_status(
@@ -99,7 +128,9 @@ class CRUDLocalFlight:
db: Session, db: Session,
flight_id: int, flight_id: int,
status: LocalFlightStatus, status: LocalFlightStatus,
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[LocalFlight]: ) -> Optional[LocalFlight]:
db_obj = self.get(db, flight_id) db_obj = self.get(db, flight_id)
if not db_obj: if not db_obj:
@@ -109,6 +140,7 @@ class CRUDLocalFlight:
if isinstance(status, str): if isinstance(status, str):
status = LocalFlightStatus(status) status = LocalFlightStatus(status)
old_status = db_obj.status
db_obj.status = status db_obj.status = status
# Set timestamps based on status # Set timestamps based on status
@@ -121,6 +153,17 @@ class CRUDLocalFlight:
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) 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 return db_obj
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]: def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:

View File

@@ -98,11 +98,22 @@ class CRUDPPR:
return db_obj 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: 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) update_data = obj_in.dict(exclude_unset=True)
changes = [] changes = []
for field, value in update_data.items(): for field, value in update_data.items():
old_value = getattr(db_obj, field) 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: if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'") changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value) setattr(db_obj, field, value)
@@ -114,7 +125,7 @@ class CRUDPPR:
# Log changes in journal # Log changes in journal
for change in changes: 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 return db_obj
@@ -146,7 +157,7 @@ class CRUDPPR:
db.refresh(db_obj) db.refresh(db_obj)
# Log status change in journal # Log status change in journal
crud_journal.log_change( crud_journal.log_ppr_change(
db, db,
db_obj.id, db_obj.id,
f"Status changed from {old_status.value} to {status.value}", 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 from app.api.api import api_router
# Import models to ensure they're registered with SQLAlchemy # 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.local_flight import LocalFlight
from app.models.departure import Departure from app.models.departure import Departure
from app.models.arrival import Arrival 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()) 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): class Airport(Base):
__tablename__ = "airports" __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> <p style="color: #666; font-style: italic;">Loading circuits...</p>
</div> </div>
</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> </div>
</div> </div>
@@ -650,6 +658,14 @@
</button> </button>
</div> </div>
</form> </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> </div>
</div> </div>
@@ -710,6 +726,14 @@
</button> </button>
</div> </div>
</form> </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> </div>
</div> </div>
@@ -1604,15 +1628,27 @@
function displayDeparted(departed) { function displayDeparted(departed) {
const tbody = document.getElementById('departed-table-body'); 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'; document.getElementById('departed-no-data').style.display = 'block';
return; return;
} }
// Sort by departed time // Sort by departed time
departed.sort((a, b) => { uniqueDeparted.sort((a, b) => {
const aTime = a.departed_dt; const aTime = a.departed_dt;
const bTime = b.departed_dt; const bTime = b.departed_dt;
return new Date(aTime) - new Date(bTime); return new Date(aTime) - new Date(bTime);
@@ -1621,7 +1657,7 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('departed-table-content').style.display = 'block'; document.getElementById('departed-table-content').style.display = 'block';
for (const flight of departed) { for (const flight of uniqueDeparted) {
const row = document.createElement('tr'); const row = document.createElement('tr');
const isLocal = flight.isLocalFlight; const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture; const isDeparture = flight.isDeparture;
@@ -1726,15 +1762,27 @@
function displayParked(parked) { function displayParked(parked) {
const tbody = document.getElementById('parked-table-body'); 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'; document.getElementById('parked-no-data').style.display = 'block';
return; return;
} }
// Sort by landed time // Sort by landed time
parked.sort((a, b) => { uniqueParked.sort((a, b) => {
if (!a.landed_dt) return 1; if (!a.landed_dt) return 1;
if (!b.landed_dt) return -1; if (!b.landed_dt) return -1;
return new Date(a.landed_dt) - new Date(b.landed_dt); return new Date(a.landed_dt) - new Date(b.landed_dt);
@@ -1743,7 +1791,7 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('parked-table-content').style.display = 'block'; document.getElementById('parked-table-content').style.display = 'block';
for (const ppr of parked) { for (const ppr of uniqueParked) {
const row = document.createElement('tr'); const row = document.createElement('tr');
const isBookedIn = ppr.isBookedIn; const isBookedIn = ppr.isBookedIn;
@@ -1841,20 +1889,32 @@
function displayUpcoming(upcoming) { function displayUpcoming(upcoming) {
const tbody = document.getElementById('upcoming-table-body'); 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 // Don't show anything if collapsed by default
return; return;
} }
// Sort by ETA date and time // 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 = ''; tbody.innerHTML = '';
// Don't auto-expand, keep collapsed by default // Don't auto-expand, keep collapsed by default
for (const ppr of upcoming) { for (const ppr of uniqueUpcoming) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id); row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important;'; row.style.cssText = 'font-size: 0.85rem !important;';
@@ -1932,14 +1992,26 @@
async function displayArrivals(arrivals) { async function displayArrivals(arrivals) {
const tbody = document.getElementById('arrivals-table-body'); const tbody = document.getElementById('arrivals-table-body');
const recordCount = document.getElementById('arrivals-count'); 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'; document.getElementById('arrivals-no-data').style.display = 'block';
return; return;
} }
// Sort arrivals by ETA/departure time (ascending) // Sort arrivals by ETA/departure time (ascending)
arrivals.sort((a, b) => { uniqueArrivals.sort((a, b) => {
const aTime = a.eta || a.departure_dt; const aTime = a.eta || a.departure_dt;
const bTime = b.eta || b.departure_dt; const bTime = b.eta || b.departure_dt;
if (!aTime) return 1; if (!aTime) return 1;
@@ -1948,7 +2020,7 @@
}); });
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block'; document.getElementById('arrivals-table-content').style.display = 'block';
for (const flight of arrivals) { for (const flight of uniqueArrivals) {
const row = document.createElement('tr'); const row = document.createElement('tr');
const isLocal = flight.isLocalFlight; const isLocal = flight.isLocalFlight;
const isBookedIn = flight.isBookedIn; const isBookedIn = flight.isBookedIn;
@@ -2091,14 +2163,26 @@
async function displayDepartures(departures) { async function displayDepartures(departures) {
const tbody = document.getElementById('departures-table-body'); const tbody = document.getElementById('departures-table-body');
const recordCount = document.getElementById('departures-count'); 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'; document.getElementById('departures-no-data').style.display = 'block';
return; return;
} }
// Sort departures by ETD (ascending), nulls last // Sort departures by ETD (ascending), nulls last
departures.sort((a, b) => { uniqueDepartures.sort((a, b) => {
const aTime = a.etd || a.created_dt; const aTime = a.etd || a.created_dt;
const bTime = b.etd || b.created_dt; const bTime = b.etd || b.created_dt;
if (!aTime) return 1; if (!aTime) return 1;
@@ -2107,7 +2191,7 @@
}); });
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block'; document.getElementById('departures-table-content').style.display = 'block';
for (const flight of departures) { for (const flight of uniqueDepartures) {
const row = document.createElement('tr'); const row = document.createElement('tr');
const isLocal = flight.isLocalFlight; const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture; 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 { try {
const response = await fetch(`/api/v1/pprs/${pprId}/journal`, { const response = await fetch(`/api/v1/journal/${entityType}/${entityId}`, {
headers: { headers: {
'Authorization': `Bearer ${accessToken}` 'Authorization': `Bearer ${accessToken}`
} }
@@ -2473,16 +2558,40 @@
throw new Error('Failed to fetch journal'); throw new Error('Failed to fetch journal');
} }
const entries = await response.json(); const data = await response.json();
displayJournal(entries); // The new API returns { entity_type, entity_id, entries, total_entries }
displayJournalForContainer(data.entries || [], containerElementId);
} catch (error) { } catch (error) {
console.error('Error loading journal:', 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) { // PPR-specific journal loader (backward compatible)
const container = document.getElementById('journal-entries'); 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) { if (entries.length === 0) {
container.innerHTML = '<p>No journal entries yet.</p>'; container.innerHTML = '<p>No journal entries yet.</p>';
@@ -2496,6 +2605,10 @@
</div> </div>
`).join(''); `).join('');
} }
}
function displayJournal(entries) {
displayJournalForContainer(entries, 'journal-entries');
// Always show journal section when displaying entries // Always show journal section when displaying entries
document.getElementById('journal-section').style.display = 'block'; document.getElementById('journal-section').style.display = 'block';
@@ -3390,6 +3503,9 @@
} }
document.getElementById('localFlightEditModal').style.display = 'block'; document.getElementById('localFlightEditModal').style.display = 'block';
// Load journal for this local flight
await loadLocalFlightJournal(flightId);
} catch (error) { } catch (error) {
console.error('Error loading flight:', error); console.error('Error loading flight:', error);
showNotification('Error loading flight details', true); showNotification('Error loading flight details', true);
@@ -3472,6 +3588,9 @@
document.getElementById('departure-edit-title').textContent = `${departure.registration} to ${departure.out_to}`; document.getElementById('departure-edit-title').textContent = `${departure.registration} to ${departure.out_to}`;
document.getElementById('departureEditModal').style.display = 'block'; document.getElementById('departureEditModal').style.display = 'block';
// Load journal for this departure
await loadDepartureJournal(departureId);
} catch (error) { } catch (error) {
console.error('Error loading departure:', error); console.error('Error loading departure:', error);
showNotification('Error loading departure details', true); showNotification('Error loading departure details', true);
@@ -3573,6 +3692,9 @@
// Show modal // Show modal
document.getElementById('arrivalEditModal').style.display = 'block'; document.getElementById('arrivalEditModal').style.display = 'block';
// Load journal for this arrival
await loadArrivalJournal(arrivalId);
} catch (error) { } catch (error) {
console.error('Error loading arrival:', error); console.error('Error loading arrival:', error);
showNotification('Error loading arrival details', true); showNotification('Error loading arrival details', true);