Inital stab at local flights
This commit is contained in:
58
backend/alembic/versions/002_local_flights.py
Normal file
58
backend/alembic/versions/002_local_flights.py
Normal file
@@ -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')
|
||||
@@ -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"])
|
||||
195
backend/app/api/endpoints/local_flights.py
Normal file
195
backend/app/api/endpoints/local_flights.py
Normal file
@@ -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)
|
||||
136
backend/app/crud/crud_local_flight.py
Normal file
136
backend/app/crud/crud_local_flight.py
Normal file
@@ -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()
|
||||
@@ -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__)
|
||||
|
||||
35
backend/app/models/local_flight.py
Normal file
35
backend/app/models/local_flight.py
Normal file
@@ -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())
|
||||
81
backend/app/schemas/local_flight.py
Normal file
81
backend/app/schemas/local_flight.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user