From a2682314c96cdeed215fd4405ded40b50eaf3bf6 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Thu, 18 Dec 2025 07:34:19 -0500 Subject: [PATCH] Journaling for all flights --- backend/alembic/versions/002_local_flights.py | 36 +++- backend/app/api/api.py | 3 +- backend/app/api/endpoints/arrivals.py | 16 +- backend/app/api/endpoints/departures.py | 16 +- backend/app/api/endpoints/journal.py | 63 +++++++ backend/app/api/endpoints/local_flights.py | 16 +- backend/app/crud/crud_arrival.py | 55 +++++- backend/app/crud/crud_departure.py | 55 +++++- backend/app/crud/crud_journal.py | 100 ++++++++-- backend/app/crud/crud_local_flight.py | 55 +++++- backend/app/crud/crud_ppr.py | 15 +- backend/app/main.py | 3 +- backend/app/models/journal.py | 33 ++++ backend/app/models/ppr.py | 11 -- backend/app/schemas/journal.py | 28 +++ web/admin.html | 176 +++++++++++++++--- 16 files changed, 594 insertions(+), 87 deletions(-) create mode 100644 backend/app/api/endpoints/journal.py create mode 100644 backend/app/models/journal.py create mode 100644 backend/app/schemas/journal.py diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py index 4c102c8..9b5f401 100644 --- a/backend/alembic/versions/002_local_flights.py +++ b/backend/alembic/versions/002_local_flights.py @@ -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), diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 488cd5c..0f709a1 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -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"]) \ No newline at end of file diff --git a/backend/app/api/endpoints/arrivals.py b/backend/app/api/endpoints/arrivals.py index c625004..0937d4b 100644 --- a/backend/app/api/endpoints/arrivals.py +++ b/backend/app/api/endpoints/arrivals.py @@ -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( diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py index 970bd94..f5d8a2a 100644 --- a/backend/app/api/endpoints/departures.py +++ b/backend/app/api/endpoints/departures.py @@ -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( diff --git a/backend/app/api/endpoints/journal.py b/backend/app/api/endpoints/journal.py new file mode 100644 index 0000000..b409e11 --- /dev/null +++ b/backend/app/api/endpoints/journal.py @@ -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 diff --git a/backend/app/api/endpoints/local_flights.py b/backend/app/api/endpoints/local_flights.py index 30dcfed..78dcd78 100644 --- a/backend/app/api/endpoints/local_flights.py +++ b/backend/app/api/endpoints/local_flights.py @@ -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( diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py index 4abd830..1e5e9dc 100644 --- a/backend/app/crud/crud_arrival.py +++ b/backend/app/crud/crud_arrival.py @@ -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]: diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py index 7533ed8..a6fd2c1 100644 --- a/backend/app/crud/crud_departure.py +++ b/backend/app/crud/crud_departure.py @@ -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]: diff --git a/backend/app/crud/crud_journal.py b/backend/app/crud/crud_journal.py index 669527f..9d8c6d6 100644 --- a/backend/app/crud/crud_journal.py +++ b/backend/app/crud/crud_journal.py @@ -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() \ No newline at end of file diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py index 25c2b12..7d432cf 100644 --- a/backend/app/crud/crud_local_flight.py +++ b/backend/app/crud/crud_local_flight.py @@ -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]: diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index 2d16e75..853d518 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -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}", diff --git a/backend/app/main.py b/backend/app/main.py index dad5715..288d53d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py new file mode 100644 index 0000000..95e238b --- /dev/null +++ b/backend/app/models/journal.py @@ -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'), + ) diff --git a/backend/app/models/ppr.py b/backend/app/models/ppr.py index b23b9a8..9ccc08c 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -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" diff --git a/backend/app/schemas/journal.py b/backend/app/schemas/journal.py new file mode 100644 index 0000000..77e799f --- /dev/null +++ b/backend/app/schemas/journal.py @@ -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 diff --git a/web/admin.html b/web/admin.html index 834c4a7..0e15d7a 100644 --- a/web/admin.html +++ b/web/admin.html @@ -528,6 +528,14 @@

Loading circuits...

+ + +
+

📋 Activity Journal

+
+ Loading journal... +
+
@@ -650,6 +658,14 @@ + + +
+

📋 Activity Journal

+
+ Loading journal... +
+
@@ -710,6 +726,14 @@ + + +
+

📋 Activity Journal

+
+ Loading journal... +
+
@@ -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 = '

No journal entries yet.

'; @@ -2496,6 +2605,10 @@ `).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);