Pilot self-bookout

This commit is contained in:
2026-02-20 11:52:43 -05:00
parent 24971ac5fc
commit 7f4e4a8459
14 changed files with 1354 additions and 46 deletions

View File

@@ -0,0 +1,82 @@
"""Add public booking support with submitted_via and pilot_email columns
Revision ID: 003_public_booking
Revises: 002_local_flights
Create Date: 2026-02-20 12:00:00.000000
This migration adds support for public flight booking by adding:
- submitted_via enum field to track ADMIN vs PUBLIC submissions
- pilot_email field to store contact info for public submissions
- Indexes on submitted_via for filtering queries
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_public_booking'
down_revision = '002_local_flights'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Add public booking support columns to local_flights, departures, and arrivals tables.
"""
# Create the SubmissionSource enum type
submission_source_enum = sa.Enum('ADMIN', 'PUBLIC', name='submissionsource')
# Add submitted_via and pilot_email to local_flights table
op.add_column('local_flights', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('local_flights', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on local_flights
op.create_index('idx_lf_submitted_via', 'local_flights', ['submitted_via'])
op.create_index('idx_lf_pilot_email', 'local_flights', ['pilot_email'])
# Add submitted_via and pilot_email to departures table
op.add_column('departures', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('departures', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on departures
op.create_index('idx_dep_submitted_via', 'departures', ['submitted_via'])
op.create_index('idx_dep_pilot_email', 'departures', ['pilot_email'])
# Add submitted_via and pilot_email to arrivals table
op.add_column('arrivals', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('arrivals', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on arrivals
op.create_index('idx_arr_submitted_via', 'arrivals', ['submitted_via'])
op.create_index('idx_arr_pilot_email', 'arrivals', ['pilot_email'])
def downgrade() -> None:
"""
Remove the submitted_via and pilot_email columns from local_flights, departures, and arrivals tables.
"""
# Drop indexes first
op.drop_index('idx_lf_submitted_via', table_name='local_flights')
op.drop_index('idx_lf_pilot_email', table_name='local_flights')
op.drop_index('idx_dep_submitted_via', table_name='departures')
op.drop_index('idx_dep_pilot_email', table_name='departures')
op.drop_index('idx_arr_submitted_via', table_name='arrivals')
op.drop_index('idx_arr_pilot_email', table_name='arrivals')
# Drop columns from local_flights
op.drop_column('local_flights', 'pilot_email')
op.drop_column('local_flights', 'submitted_via')
# Drop columns from departures
op.drop_column('departures', 'pilot_email')
op.drop_column('departures', 'submitted_via')
# Drop columns from arrivals
op.drop_column('arrivals', 'pilot_email')
op.drop_column('arrivals', 'submitted_via')
# Drop the enum type
op.execute('DROP TYPE IF EXISTS submissionsource')

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book
api_router = APIRouter()
@@ -12,5 +12,6 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
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(public_book.router, prefix="/public-book", tags=["public_booking"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])

View File

@@ -0,0 +1,207 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.config import settings
from app.schemas.public_book import (
PublicLocalFlightCreate,
PublicCircuitCreate,
PublicDepartureCreate,
PublicArrivalCreate,
)
from app.schemas.local_flight import LocalFlight as LocalFlightSchema
from app.schemas.circuit import Circuit as CircuitSchema
from app.schemas.departure import Departure as DepartureSchema
from app.schemas.arrival import Arrival as ArrivalSchema
from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.crud.crud_circuit import crud_circuit
from app.crud.crud_departure import departure as crud_departure
from app.crud.crud_arrival import arrival as crud_arrival
from app.models.local_flight import SubmissionSource
from app.models.departure import SubmissionSource as DepartureSubmissionSource
from app.models.arrival import SubmissionSource as ArrivalSubmissionSource
router = APIRouter()
def check_public_booking_enabled():
"""Check if public booking is enabled"""
if not settings.allow_public_booking:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Public booking is currently disabled"
)
@router.post("/local-flights", response_model=LocalFlightSchema)
async def public_book_local_flight(
request: Request,
flight_in: PublicLocalFlightCreate,
db: Session = Depends(get_db),
):
"""Book a local flight via public portal"""
check_public_booking_enabled()
# Create the flight with public submission source
from app.schemas.local_flight import LocalFlightCreate
flight_create = LocalFlightCreate(
registration=flight_in.registration,
type=flight_in.type,
callsign=flight_in.callsign,
pob=flight_in.pob,
flight_type=flight_in.flight_type,
duration=flight_in.duration,
etd=flight_in.etd,
notes=flight_in.notes,
)
flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT")
# Update with submission source and pilot email
db.query(type(flight)).filter(type(flight).id == flight.id).update({
type(flight).submitted_via: SubmissionSource.PUBLIC,
type(flight).pilot_email: flight_in.pilot_email,
})
db.commit()
db.refresh(flight)
# Send real-time update via WebSocket if available
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,
"submitted_via": "PUBLIC"
}
})
return flight
@router.post("/circuits", response_model=CircuitSchema)
async def public_record_circuit(
request: Request,
circuit_in: PublicCircuitCreate,
db: Session = Depends(get_db),
):
"""Record a circuit (touch and go) via public portal"""
check_public_booking_enabled()
from app.schemas.circuit import CircuitCreate
circuit_create = CircuitCreate(
local_flight_id=circuit_in.local_flight_id,
circuit_timestamp=circuit_in.circuit_timestamp,
)
circuit = crud_circuit.create(db, obj_in=circuit_create)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "circuit_recorded",
"data": {
"id": circuit.id,
"local_flight_id": circuit.local_flight_id,
"circuit_timestamp": circuit.circuit_timestamp.isoformat(),
"submitted_via": "PUBLIC"
}
})
return circuit
@router.post("/departures", response_model=DepartureSchema)
async def public_book_departure(
request: Request,
departure_in: PublicDepartureCreate,
db: Session = Depends(get_db),
):
"""Book a departure via public portal"""
check_public_booking_enabled()
from app.schemas.departure import DepartureCreate
departure_create = DepartureCreate(
registration=departure_in.registration,
type=departure_in.type,
callsign=departure_in.callsign,
pob=departure_in.pob,
out_to=departure_in.out_to,
etd=departure_in.etd,
notes=departure_in.notes,
)
departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT")
# Update with submission source and pilot email
db.query(type(departure)).filter(type(departure).id == departure.id).update({
type(departure).submitted_via: DepartureSubmissionSource.PUBLIC,
type(departure).pilot_email: departure_in.pilot_email,
})
db.commit()
db.refresh(departure)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_booked_out",
"data": {
"id": departure.id,
"registration": departure.registration,
"status": departure.status.value,
"submitted_via": "PUBLIC"
}
})
return departure
@router.post("/arrivals", response_model=ArrivalSchema)
async def public_book_arrival(
request: Request,
arrival_in: PublicArrivalCreate,
db: Session = Depends(get_db),
):
"""Book an arrival via public portal"""
check_public_booking_enabled()
from app.schemas.arrival import ArrivalCreate
arrival_create = ArrivalCreate(
registration=arrival_in.registration,
type=arrival_in.type,
callsign=arrival_in.callsign,
pob=arrival_in.pob,
in_from=arrival_in.in_from,
eta=arrival_in.eta,
notes=arrival_in.notes,
)
arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT")
# Update with submission source and pilot email
db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({
type(arrival).submitted_via: ArrivalSubmissionSource.PUBLIC,
type(arrival).pilot_email: arrival_in.pilot_email,
})
db.commit()
db.refresh(arrival)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in",
"data": {
"id": arrival.id,
"registration": arrival.registration,
"status": arrival.status.value,
"submitted_via": "PUBLIC"
}
})
return arrival

View File

@@ -33,6 +33,9 @@ class Settings(BaseSettings):
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Public booking settings
allow_public_booking: bool = False # Enable/disable public flight booking
# Redis settings (for future use)
redis_url: Optional[str] = None

View File

@@ -6,6 +6,11 @@ from datetime import datetime
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
LANDED = "LANDED"
@@ -27,4 +32,6 @@ class Arrival(Base):
eta = Column(DateTime, nullable=True, index=True)
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -6,6 +6,11 @@ from datetime import datetime
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
DEPARTED = "DEPARTED"
@@ -27,4 +32,6 @@ class Departure(Base):
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
departed_dt = Column(DateTime, nullable=True) # Actual departure time
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -4,6 +4,11 @@ from enum import Enum
from app.db.session import Base
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
@@ -35,4 +40,6 @@ class LocalFlight(Base):
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())

View File

@@ -10,6 +10,11 @@ class ArrivalStatus(str, Enum):
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalBase(BaseModel):
registration: str
type: Optional[str] = None
@@ -63,6 +68,8 @@ class Arrival(ArrivalBase):
landed_dt: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -10,6 +10,11 @@ class DepartureStatus(str, Enum):
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureBase(BaseModel):
registration: str
type: Optional[str] = None
@@ -63,3 +68,9 @@ class Departure(DepartureBase):
created_dt: datetime
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -17,6 +17,11 @@ class LocalFlightStatus(str, Enum):
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
@@ -81,6 +86,8 @@ class LocalFlightInDBBase(LocalFlightBase):
circuits: Optional[int] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,129 @@
from pydantic import BaseModel, validator, EmailStr
from datetime import datetime
from typing import Optional
from enum import Enum
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
DEPARTURE = "DEPARTURE"
class PublicLocalFlightCreate(BaseModel):
"""Schema for public local flight booking"""
registration: str
type: Optional[str] = None # Aircraft type - optional
callsign: Optional[str] = None
pob: int
flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
pilot_email: Optional[str] = None # Pilot's email for contact (optional)
pilot_name: Optional[str] = None # Pilot's name
@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('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
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicCircuitCreate(BaseModel):
"""Schema for public circuit (touch and go) recording"""
local_flight_id: int
circuit_timestamp: datetime
pilot_email: Optional[str] = None
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicDepartureCreate(BaseModel):
"""Schema for public departure booking"""
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
out_to: str
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
pilot_email: Optional[str] = None
pilot_name: 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('out_to')
def validate_out_to(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Destination airport 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
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicArrivalCreate(BaseModel):
"""Schema for public arrival booking"""
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
in_from: str
eta: Optional[datetime] = None
notes: Optional[str] = None
pilot_email: Optional[str] = None
pilot_name: 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('in_from')
def validate_in_from(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Origin airport 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
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()