From b46a88d47192ace167143e57b0f288a8d3b0e173 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Fri, 19 Dec 2025 05:07:46 -0500 Subject: [PATCH] Overflights implementation --- backend/alembic/versions/002_local_flights.py | 33 +- backend/app/api/api.py | 3 +- backend/app/api/endpoints/overflights.py | 206 ++++++++++ backend/app/crud/crud_overflight.py | 172 ++++++++ backend/app/models/journal.py | 1 + backend/app/models/overflight.py | 28 ++ backend/app/schemas/overflight.py | 106 +++++ web/admin.css | 9 + web/admin.html | 378 +++++++++++++++++- web/lookups.js | 28 ++ 10 files changed, 959 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/endpoints/overflights.py create mode 100644 backend/app/crud/crud_overflight.py create mode 100644 backend/app/models/overflight.py create mode 100644 backend/app/schemas/overflight.py diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py index d0162f7..71911e5 100644 --- a/backend/alembic/versions/002_local_flights.py +++ b/backend/alembic/versions/002_local_flights.py @@ -173,12 +173,43 @@ def upgrade() -> None: # Create indexes for circuits op.create_index('idx_circuit_local_flight_id', 'circuits', ['local_flight_id']) op.create_index('idx_circuit_timestamp', 'circuits', ['circuit_timestamp']) + + # Create overflights table for tracking aircraft talking to the tower but not departing/landing + op.create_table('overflights', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('registration', sa.String(length=16), nullable=False), + sa.Column('pob', sa.Integer(), nullable=True), + sa.Column('type', sa.String(length=32), nullable=True), + sa.Column('departure_airfield', sa.String(length=64), nullable=True), + sa.Column('destination_airfield', sa.String(length=64), nullable=True), + sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'CANCELLED', name='overflightstatus'), nullable=False, server_default='ACTIVE'), + sa.Column('call_dt', sa.DateTime(), nullable=False), + sa.Column('qsy_dt', sa.DateTime(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('created_by', sa.String(length=16), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + mysql_collate='utf8mb4_unicode_ci' + ) + + # Create indexes for overflights + op.create_index('idx_ovf_registration', 'overflights', ['registration']) + op.create_index('idx_ovf_departure_airfield', 'overflights', ['departure_airfield']) + op.create_index('idx_ovf_destination_airfield', 'overflights', ['destination_airfield']) + op.create_index('idx_ovf_status', 'overflights', ['status']) + op.create_index('idx_ovf_call_dt', 'overflights', ['call_dt']) + op.create_index('idx_ovf_created_dt', 'overflights', ['created_dt']) + op.create_index('idx_ovf_created_by', 'overflights', ['created_by']) def downgrade() -> None: """ - Drop the circuits, arrivals, departures, and local_flights tables. + Drop the overflights, circuits, arrivals, departures, and local_flights tables. """ + op.drop_table('overflights') op.drop_table('circuits') op.drop_table('arrivals') op.drop_table('departures') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 0f709a1..2a518c5 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, journal +from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"]) api_router.include_router(local_flights.router, prefix="/local-flights", tags=["local_flights"]) api_router.include_router(departures.router, prefix="/departures", tags=["departures"]) api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"]) +api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"]) 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"]) diff --git a/backend/app/api/endpoints/overflights.py b/backend/app/api/endpoints/overflights.py new file mode 100644 index 0000000..dad3946 --- /dev/null +++ b/backend/app/api/endpoints/overflights.py @@ -0,0 +1,206 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from datetime import date +from app.api.deps import get_db, get_current_read_user, get_current_operator_user +from app.crud.crud_overflight import overflight as crud_overflight +from app.schemas.overflight import Overflight, OverflightCreate, OverflightUpdate, OverflightStatus, OverflightStatusUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[Overflight]) +async def get_overflights( + request: Request, + skip: int = 0, + limit: int = 100, + status: Optional[OverflightStatus] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get overflight records with optional filtering""" + overflights = crud_overflight.get_multi( + db, skip=skip, limit=limit, status=status, + date_from=date_from, date_to=date_to + ) + return overflights + + +@router.post("/", response_model=Overflight) +async def create_overflight( + request: Request, + overflight_in: OverflightCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Create a new overflight record""" + overflight = crud_overflight.create(db, obj_in=overflight_in, created_by=current_user.username) + + # Send real-time update via WebSocket + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_created", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "departure_airfield": overflight.departure_airfield, + "destination_airfield": overflight.destination_airfield, + "status": overflight.status.value + } + }) + + return overflight + + +@router.get("/{overflight_id}", response_model=Overflight) +async def get_overflight( + overflight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific overflight record""" + overflight = crud_overflight.get(db, overflight_id=overflight_id) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + return overflight + + +@router.put("/{overflight_id}", response_model=Overflight) +async def update_overflight( + request: Request, + overflight_id: int, + overflight_in: OverflightUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update an overflight record""" + db_overflight = crud_overflight.get(db, overflight_id=overflight_id) + if not db_overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Get user IP from request + user_ip = request.client.host if request.client else None + + overflight = crud_overflight.update( + db, + db_obj=db_overflight, + obj_in=overflight_in, + user=current_user.username, + user_ip=user_ip + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_updated", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "status": overflight.status.value + } + }) + + return overflight + + +@router.patch("/{overflight_id}/status", response_model=Overflight) +async def update_overflight_status( + request: Request, + overflight_id: int, + status_update: OverflightStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update overflight status (ACTIVE -> INACTIVE for QSY)""" + client_ip = get_client_ip(request) + overflight = crud_overflight.update_status( + db, + overflight_id=overflight_id, + status=status_update.status, + timestamp=status_update.timestamp if hasattr(status_update, 'timestamp') else None, + user=current_user.username, + user_ip=client_ip + ) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_status_update", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "status": overflight.status.value, + "qsy_dt": overflight.qsy_dt.isoformat() if overflight.qsy_dt else None + } + }) + + return overflight + + +@router.delete("/{overflight_id}", response_model=Overflight) +async def cancel_overflight( + request: Request, + overflight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Cancel an overflight record""" + client_ip = get_client_ip(request) + overflight = crud_overflight.cancel( + db, + overflight_id=overflight_id, + user=current_user.username, + user_ip=client_ip + ) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_cancelled", + "data": { + "id": overflight.id, + "registration": overflight.registration + } + }) + + return overflight + + +@router.get("/active/list", response_model=List[Overflight]) +async def get_active_overflights( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get currently active overflights""" + overflights = crud_overflight.get_active_overflights(db) + return overflights + + +@router.get("/today/list", response_model=List[Overflight]) +async def get_overflights_today( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get today's overflights""" + overflights = crud_overflight.get_overflights_today(db) + return overflights diff --git a/backend/app/crud/crud_overflight.py b/backend/app/crud/crud_overflight.py new file mode 100644 index 0000000..0b6daf9 --- /dev/null +++ b/backend/app/crud/crud_overflight.py @@ -0,0 +1,172 @@ +from typing import List, Optional +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, func, desc +from datetime import date, datetime +from app.models.overflight import Overflight, OverflightStatus +from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate +from app.models.journal import EntityType +from app.crud.crud_journal import journal + + +class CRUDOverflight: + def get(self, db: Session, overflight_id: int) -> Optional[Overflight]: + return db.query(Overflight).filter(Overflight.id == overflight_id).first() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[OverflightStatus] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None + ) -> List[Overflight]: + query = db.query(Overflight) + + if status: + query = query.filter(Overflight.status == status) + + if date_from: + query = query.filter(func.date(Overflight.created_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Overflight.created_dt) <= date_to) + + return query.order_by(desc(Overflight.created_dt)).offset(skip).limit(limit).all() + + def get_active_overflights(self, db: Session) -> List[Overflight]: + """Get currently active overflights""" + return db.query(Overflight).filter( + Overflight.status == OverflightStatus.ACTIVE + ).order_by(desc(Overflight.created_dt)).all() + + def get_overflights_today(self, db: Session) -> List[Overflight]: + """Get today's overflights""" + today = date.today() + return db.query(Overflight).filter( + func.date(Overflight.created_dt) == today + ).order_by(Overflight.created_dt).all() + + def create(self, db: Session, obj_in: OverflightCreate, created_by: str) -> Overflight: + db_obj = Overflight( + **obj_in.dict(), + created_by=created_by, + status=OverflightStatus.ACTIVE + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log creation in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + db_obj.id, + f"Overflight created: {obj_in.registration} from {obj_in.departure_airfield} to {obj_in.destination_airfield}", + created_by, + None + ) + + return db_obj + + def update(self, db: Session, db_obj: Overflight, obj_in: OverflightUpdate, user: str = "system", user_ip: Optional[str] = None) -> Overflight: + 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): + 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 + + if old_value != value: + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) + + if changes: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log changes in journal + for change in changes: + journal.log_change( + db, + EntityType.OVERFLIGHT, + db_obj.id, + change, + user, + user_ip + ) + + return db_obj + + def update_status( + self, + db: Session, + overflight_id: int, + status: OverflightStatus, + timestamp: Optional[datetime] = None, + user: str = "system", + user_ip: Optional[str] = None + ) -> Optional[Overflight]: + db_obj = self.get(db, overflight_id) + if not db_obj: + return None + + # Ensure status is an OverflightStatus enum + if isinstance(status, str): + status = OverflightStatus(status) + + old_status = db_obj.status + db_obj.status = status + + # Set timestamp if transitioning to INACTIVE (QSY'd) + current_time = timestamp if timestamp is not None else datetime.utcnow() + if status == OverflightStatus.INACTIVE: + db_obj.qsy_dt = current_time + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log status change in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + overflight_id, + f"Status changed from {old_status.value} to {status.value}", + user, + user_ip + ) + + return db_obj + + def cancel(self, db: Session, overflight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Overflight]: + db_obj = self.get(db, overflight_id) + if db_obj: + old_status = db_obj.status + db_obj.status = OverflightStatus.CANCELLED + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log cancellation in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + overflight_id, + f"Status changed from {old_status.value} to CANCELLED", + user, + user_ip + ) + return db_obj + + +overflight = CRUDOverflight() diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py index 95e238b..6a74fae 100644 --- a/backend/app/models/journal.py +++ b/backend/app/models/journal.py @@ -10,6 +10,7 @@ class EntityType(str, PyEnum): LOCAL_FLIGHT = "LOCAL_FLIGHT" ARRIVAL = "ARRIVAL" DEPARTURE = "DEPARTURE" + OVERFLIGHT = "OVERFLIGHT" class JournalEntry(Base): diff --git a/backend/app/models/overflight.py b/backend/app/models/overflight.py new file mode 100644 index 0000000..2562684 --- /dev/null +++ b/backend/app/models/overflight.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger +from sqlalchemy.sql import func +from enum import Enum +from app.db.session import Base + + +class OverflightStatus(str, Enum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + CANCELLED = "CANCELLED" + + +class Overflight(Base): + __tablename__ = "overflights" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + registration = Column(String(16), nullable=False, index=True) + pob = Column(Integer, nullable=True) # Persons on board + type = Column(String(32), nullable=True) # Aircraft type + departure_airfield = Column(String(64), nullable=True, index=True) # Airfield they departed from + destination_airfield = Column(String(64), nullable=True, index=True) # Where they're heading + status = Column(SQLEnum(OverflightStatus), nullable=False, default=OverflightStatus.ACTIVE, index=True) + call_dt = Column(DateTime, nullable=False, index=True) # Time of initial call + qsy_dt = Column(DateTime, nullable=True) # Time of frequency change (QSY) + notes = Column(Text, nullable=True) + created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) + created_by = Column(String(16), nullable=True, index=True) + updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) diff --git a/backend/app/schemas/overflight.py b/backend/app/schemas/overflight.py new file mode 100644 index 0000000..ac66580 --- /dev/null +++ b/backend/app/schemas/overflight.py @@ -0,0 +1,106 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from enum import Enum + + +class OverflightStatus(str, Enum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + CANCELLED = "CANCELLED" + + +class OverflightBase(BaseModel): + registration: str # Using registration as callsign + pob: Optional[int] = None + type: Optional[str] = None # Aircraft type + departure_airfield: Optional[str] = None + destination_airfield: Optional[str] = None + call_dt: datetime # Time of initial call + qsy_dt: Optional[datetime] = None # Time of frequency change + notes: Optional[str] = None + + @validator('registration') + def validate_registration(cls, v): + if not v or len(v.strip()) == 0: + raise ValueError('Aircraft registration is required') + return v.strip().upper() + + @validator('type') + def validate_type(cls, v): + if v and len(v.strip()) > 0: + return v.strip() + return v + + @validator('departure_airfield') + def validate_departure_airfield(cls, v): + if v and len(v.strip()) > 0: + return v.strip().upper() + return v + + @validator('destination_airfield') + def validate_destination_airfield(cls, v): + if v and len(v.strip()) > 0: + return v.strip().upper() + return v + + @validator('pob') + def validate_pob(cls, v): + if v is not None and v < 1: + raise ValueError('Persons on board must be at least 1') + return v + + +class OverflightCreate(OverflightBase): + pass + + +class OverflightUpdate(BaseModel): + callsign: Optional[str] = None + pob: Optional[int] = None + type: Optional[str] = None + departure_airfield: Optional[str] = None + destination_airfield: Optional[str] = None + call_dt: Optional[datetime] = None + qsy_dt: Optional[datetime] = None + status: Optional[OverflightStatus] = None + notes: Optional[str] = None + + @validator('type') + def validate_type(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip() if v else v + + @validator('departure_airfield') + def validate_departure_airfield(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip().upper() if v else v + + @validator('destination_airfield') + def validate_destination_airfield(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip().upper() if v else v + + @validator('pob') + def validate_pob(cls, v): + if v is not None and v < 1: + raise ValueError('Persons on board must be at least 1') + return v + + +class OverflightStatusUpdate(BaseModel): + status: OverflightStatus + + +class Overflight(OverflightBase): + id: int + status: OverflightStatus + created_dt: datetime + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True diff --git a/web/admin.css b/web/admin.css index 2729738..6c2ac3a 100644 --- a/web/admin.css +++ b/web/admin.css @@ -96,6 +96,15 @@ body { background-color: #2980b9; } +.btn-secondary { + background-color: #95a5a6; + color: white; +} + +.btn-secondary:hover { + background-color: #7f8c8d; +} + .btn-danger { background-color: #e74c3c; color: white; diff --git a/web/admin.html b/web/admin.html index 259dceb..a175286 100644 --- a/web/admin.html +++ b/web/admin.html @@ -22,6 +22,9 @@ + @@ -117,6 +120,46 @@

No aircraft currently landed and ready to depart.

+ + +
+
+
+ 🔄 Active Overflights - 0 + â„šī¸ +
+
+ +
+
+ Loading overflights... +
+ + + + +

@@ -598,6 +641,64 @@
+ + +