Journaling for all flights

This commit is contained in:
2025-12-18 07:34:19 -05:00
parent f3eb83665f
commit a2682314c9
16 changed files with 594 additions and 87 deletions

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)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
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)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
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
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()
"""CRUD operations for the generic journal table.
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,
ppr_id: int,
entity_type: EntityType,
entity_id: int,
entry: str,
user: str,
ip: str
) -> Journal:
journal_in = JournalCreate(
ppr_id=ppr_id,
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: 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)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
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