diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py new file mode 100644 index 0000000..f59411b --- /dev/null +++ b/backend/alembic/versions/002_local_flights.py @@ -0,0 +1,58 @@ +"""Add local_flights table for tracking local flights + +Revision ID: 002_local_flights +Revises: 001_initial_schema +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. + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '002_local_flights' +down_revision = '001_initial_schema' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """ + Create local_flights table for tracking aircraft that book out locally. + """ + + op.create_table('local_flights', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('registration', sa.String(length=16), nullable=False), + sa.Column('type', sa.String(length=32), nullable=False), + sa.Column('callsign', sa.String(length=16), nullable=True), + sa.Column('pob', sa.Integer(), nullable=False), + sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False), + sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('booked_out_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('departure_dt', sa.DateTime(), nullable=True), + sa.Column('landed_dt', sa.DateTime(), nullable=True), + 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 frequently queried columns + op.create_index('idx_registration', 'local_flights', ['registration']) + op.create_index('idx_flight_type', 'local_flights', ['flight_type']) + op.create_index('idx_status', 'local_flights', ['status']) + op.create_index('idx_booked_out_dt', 'local_flights', ['booked_out_dt']) + op.create_index('idx_created_by', 'local_flights', ['created_by']) + + +def downgrade() -> None: + """ + Drop the local_flights table. + """ + op.drop_table('local_flights') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 935b4fe..439e869 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -1,10 +1,11 @@ from fastapi import APIRouter -from app.api.endpoints import auth, pprs, public, aircraft, airport +from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) 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(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/local_flights.py b/backend/app/api/endpoints/local_flights.py new file mode 100644 index 0000000..30dcfed --- /dev/null +++ b/backend/app/api/endpoints/local_flights.py @@ -0,0 +1,195 @@ +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_local_flight import local_flight as crud_local_flight +from app.schemas.local_flight import LocalFlight, LocalFlightCreate, LocalFlightUpdate, LocalFlightStatus, LocalFlightType, LocalFlightStatusUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[LocalFlight]) +async def get_local_flights( + request: Request, + skip: int = 0, + limit: int = 100, + status: Optional[LocalFlightStatus] = None, + flight_type: Optional[LocalFlightType] = 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 local flight records with optional filtering""" + flights = crud_local_flight.get_multi( + db, skip=skip, limit=limit, status=status, + flight_type=flight_type, date_from=date_from, date_to=date_to + ) + return flights + + +@router.post("/", response_model=LocalFlight) +async def create_local_flight( + request: Request, + flight_in: LocalFlightCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Create a new local flight record (book out)""" + flight = crud_local_flight.create(db, obj_in=flight_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": "local_flight_booked_out", + "data": { + "id": flight.id, + "registration": flight.registration, + "flight_type": flight.flight_type.value, + "status": flight.status.value + } + }) + + return flight + + +@router.get("/{flight_id}", response_model=LocalFlight) +async def get_local_flight( + flight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific local flight record""" + flight = crud_local_flight.get(db, flight_id=flight_id) + if not flight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Local flight record not found" + ) + return flight + + +@router.put("/{flight_id}", response_model=LocalFlight) +async def update_local_flight( + request: Request, + flight_id: int, + flight_in: LocalFlightUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update a local flight record""" + db_flight = crud_local_flight.get(db, flight_id=flight_id) + if not db_flight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Local flight record not found" + ) + + flight = crud_local_flight.update(db, db_obj=db_flight, obj_in=flight_in) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "local_flight_updated", + "data": { + "id": flight.id, + "registration": flight.registration, + "status": flight.status.value + } + }) + + return flight + + +@router.patch("/{flight_id}/status", response_model=LocalFlight) +async def update_local_flight_status( + request: Request, + flight_id: int, + status_update: LocalFlightStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update local flight status (LANDED, CANCELLED, etc.)""" + flight = crud_local_flight.update_status( + db, + flight_id=flight_id, + status=status_update.status, + timestamp=status_update.timestamp + ) + if not flight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Local flight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "local_flight_status_update", + "data": { + "id": flight.id, + "registration": flight.registration, + "status": flight.status.value, + "landed_dt": flight.landed_dt.isoformat() if flight.landed_dt else None + } + }) + + return flight + + +@router.delete("/{flight_id}", response_model=LocalFlight) +async def cancel_local_flight( + request: Request, + flight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Cancel a local flight record""" + flight = crud_local_flight.cancel(db, flight_id=flight_id) + if not flight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Local flight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "local_flight_cancelled", + "data": { + "id": flight.id, + "registration": flight.registration + } + }) + + return flight + + +@router.get("/active/current", response_model=List[LocalFlight]) +async def get_active_flights( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get currently active (booked out) flights""" + return crud_local_flight.get_active_flights(db) + + +@router.get("/today/departures", response_model=List[LocalFlight]) +async def get_today_departures( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get today's departures (booked out or departed)""" + return crud_local_flight.get_departures_today(db) + + +@router.get("/today/booked-out", response_model=List[LocalFlight]) +async def get_today_booked_out( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get all flights booked out today""" + return crud_local_flight.get_booked_out_today(db) diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py new file mode 100644 index 0000000..259612d --- /dev/null +++ b/backend/app/crud/crud_local_flight.py @@ -0,0 +1,136 @@ +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.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType +from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate + + +class CRUDLocalFlight: + def get(self, db: Session, flight_id: int) -> Optional[LocalFlight]: + return db.query(LocalFlight).filter(LocalFlight.id == flight_id).first() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[LocalFlightStatus] = None, + flight_type: Optional[LocalFlightType] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None + ) -> List[LocalFlight]: + query = db.query(LocalFlight) + + if status: + query = query.filter(LocalFlight.status == status) + + if flight_type: + query = query.filter(LocalFlight.flight_type == flight_type) + + if date_from: + query = query.filter(func.date(LocalFlight.booked_out_dt) >= date_from) + + if date_to: + query = query.filter(func.date(LocalFlight.booked_out_dt) <= date_to) + + return query.order_by(desc(LocalFlight.booked_out_dt)).offset(skip).limit(limit).all() + + def get_active_flights(self, db: Session) -> List[LocalFlight]: + """Get currently active (booked out or departed) flights""" + return db.query(LocalFlight).filter( + or_( + LocalFlight.status == LocalFlightStatus.BOOKED_OUT, + LocalFlight.status == LocalFlightStatus.DEPARTED + ) + ).order_by(desc(LocalFlight.booked_out_dt)).all() + + def get_departures_today(self, db: Session) -> List[LocalFlight]: + """Get today's departures (booked out or departed)""" + today = date.today() + return db.query(LocalFlight).filter( + and_( + func.date(LocalFlight.booked_out_dt) == today, + or_( + LocalFlight.status == LocalFlightStatus.BOOKED_OUT, + LocalFlight.status == LocalFlightStatus.DEPARTED + ) + ) + ).order_by(LocalFlight.booked_out_dt).all() + + def get_booked_out_today(self, db: Session) -> List[LocalFlight]: + """Get all flights booked out today""" + today = date.today() + return db.query(LocalFlight).filter( + and_( + func.date(LocalFlight.booked_out_dt) == today, + or_( + LocalFlight.status == LocalFlightStatus.BOOKED_OUT, + LocalFlight.status == LocalFlightStatus.LANDED + ) + ) + ).order_by(LocalFlight.booked_out_dt).all() + + def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight: + db_obj = LocalFlight( + **obj_in.dict(), + created_by=created_by, + status=LocalFlightStatus.BOOKED_OUT + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate) -> LocalFlight: + update_data = obj_in.dict(exclude_unset=True) + + for field, value in update_data.items(): + if value is not None: + setattr(db_obj, field, value) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update_status( + self, + db: Session, + flight_id: int, + status: LocalFlightStatus, + timestamp: Optional[datetime] = None + ) -> Optional[LocalFlight]: + db_obj = self.get(db, flight_id) + if not db_obj: + return None + + # Ensure status is a LocalFlightStatus enum + if isinstance(status, str): + status = LocalFlightStatus(status) + + db_obj.status = status + + # Set timestamps based on status + current_time = timestamp if timestamp is not None else datetime.utcnow() + if status == LocalFlightStatus.DEPARTED: + db_obj.departure_dt = current_time + elif status == LocalFlightStatus.LANDED: + db_obj.landed_dt = current_time + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]: + db_obj = self.get(db, flight_id) + if db_obj: + db_obj.status = LocalFlightStatus.CANCELLED + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +local_flight = CRUDLocalFlight() diff --git a/backend/app/main.py b/backend/app/main.py index 17f9971..787eaea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,10 @@ import redis.asyncio as redis 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.local_flight import LocalFlight + # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py new file mode 100644 index 0000000..593e155 --- /dev/null +++ b/backend/app/models/local_flight.py @@ -0,0 +1,35 @@ +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 LocalFlightType(str, Enum): + LOCAL = "LOCAL" + CIRCUITS = "CIRCUITS" + DEPARTURE = "DEPARTURE" + + +class LocalFlightStatus(str, Enum): + BOOKED_OUT = "BOOKED_OUT" + DEPARTED = "DEPARTED" + LANDED = "LANDED" + CANCELLED = "CANCELLED" + + +class LocalFlight(Base): + __tablename__ = "local_flights" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + registration = Column(String(16), nullable=False, index=True) + type = Column(String(32), nullable=False) # Aircraft type + callsign = Column(String(16), nullable=True) + pob = Column(Integer, nullable=False) # Persons on board + flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True) + status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True) + notes = Column(Text, nullable=True) + booked_out_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) + departure_dt = Column(DateTime, nullable=True) # Actual takeoff time + landed_dt = Column(DateTime, nullable=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/local_flight.py b/backend/app/schemas/local_flight.py new file mode 100644 index 0000000..2fd1351 --- /dev/null +++ b/backend/app/schemas/local_flight.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from enum import Enum + + +class LocalFlightType(str, Enum): + LOCAL = "LOCAL" + CIRCUITS = "CIRCUITS" + DEPARTURE = "DEPARTURE" + + +class LocalFlightStatus(str, Enum): + BOOKED_OUT = "BOOKED_OUT" + DEPARTED = "DEPARTED" + LANDED = "LANDED" + CANCELLED = "CANCELLED" + + +class LocalFlightBase(BaseModel): + registration: str + type: str # Aircraft type + callsign: Optional[str] = None + pob: int + flight_type: LocalFlightType + 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 not v or len(v.strip()) == 0: + raise ValueError('Aircraft type is required') + return v.strip() + + @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 LocalFlightCreate(LocalFlightBase): + pass + + +class LocalFlightUpdate(BaseModel): + registration: Optional[str] = None + type: Optional[str] = None + callsign: Optional[str] = None + pob: Optional[int] = None + flight_type: Optional[LocalFlightType] = None + status: Optional[LocalFlightStatus] = None + departure_dt: Optional[datetime] = None + notes: Optional[str] = None + + +class LocalFlightStatusUpdate(BaseModel): + status: LocalFlightStatus + timestamp: Optional[datetime] = None + + +class LocalFlightInDBBase(LocalFlightBase): + id: int + status: LocalFlightStatus + booked_out_dt: datetime + departure_dt: Optional[datetime] = None + landed_dt: Optional[datetime] = None + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True + + +class LocalFlight(LocalFlightInDBBase): + pass diff --git a/web/admin.html b/web/admin.html index 54ca3f5..ba58310 100644 --- a/web/admin.html +++ b/web/admin.html @@ -632,6 +632,9 @@ + @@ -965,6 +968,137 @@ + + + + + +