diff --git a/backend/alembic/versions/003_public_booking.py b/backend/alembic/versions/003_public_booking.py new file mode 100644 index 0000000..53c6c10 --- /dev/null +++ b/backend/alembic/versions/003_public_booking.py @@ -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') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 2a518c5..ffd6bcb 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, 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"]) \ No newline at end of file diff --git a/backend/app/api/endpoints/public_book.py b/backend/app/api/endpoints/public_book.py new file mode 100644 index 0000000..39794f4 --- /dev/null +++ b/backend/app/api/endpoints/public_book.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 97dcca1..ee88fd8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py index e913e7c..d9fe216 100644 --- a/backend/app/models/arrival.py +++ b/backend/app/models/arrival.py @@ -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) diff --git a/backend/app/models/departure.py b/backend/app/models/departure.py index 8cb576b..a1d0a98 100644 --- a/backend/app/models/departure.py +++ b/backend/app/models/departure.py @@ -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) diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py index 0b716e6..325c5c6 100644 --- a/backend/app/models/local_flight.py +++ b/backend/app/models/local_flight.py @@ -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()) diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py index fafbf8e..063bcf2 100644 --- a/backend/app/schemas/arrival.py +++ b/backend/app/schemas/arrival.py @@ -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 diff --git a/backend/app/schemas/departure.py b/backend/app/schemas/departure.py index 181f3ee..28ef6fd 100644 --- a/backend/app/schemas/departure.py +++ b/backend/app/schemas/departure.py @@ -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 diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py index dfbc408..855c8bc 100644 --- a/backend/app/schemas/local_flight.py +++ b/backend/app/schemas/local_flight.py @@ -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 diff --git a/backend/app/schemas/public_book.py b/backend/app/schemas/public_book.py new file mode 100644 index 0000000..550536c --- /dev/null +++ b/backend/app/schemas/public_book.py @@ -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() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b74c3f6..30c3238 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,7 @@ services: REDIS_URL: ${REDIS_URL} TAG: ${TAG} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} + ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING} ENVIRONMENT: production WORKERS: "4" ports: diff --git a/web/admin.html b/web/admin.html index 95899fc..f9d3e8c 100644 --- a/web/admin.html +++ b/web/admin.html @@ -562,7 +562,7 @@ - +