diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py index f59411b..45d477e 100644 --- a/backend/alembic/versions/002_local_flights.py +++ b/backend/alembic/versions/002_local_flights.py @@ -20,13 +20,13 @@ depends_on = None def upgrade() -> None: """ - Create local_flights table for tracking aircraft that book out locally. + Create local_flights, departures, and arrivals tables. """ 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('type', sa.String(length=32), nullable=True), 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), @@ -43,16 +43,70 @@ def upgrade() -> None: mysql_collate='utf8mb4_unicode_ci' ) - # Create indexes for frequently queried columns + # Create indexes for local_flights 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']) + + # Create departures table for non-PPR departures to other airports + op.create_table('departures', + 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=True), + sa.Column('callsign', sa.String(length=16), nullable=True), + sa.Column('pob', sa.Integer(), nullable=False), + sa.Column('out_to', sa.String(length=64), nullable=False), + sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'CANCELLED', name='departuresstatus'), 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('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' + ) + + op.create_index('idx_dep_registration', 'departures', ['registration']) + op.create_index('idx_dep_out_to', 'departures', ['out_to']) + op.create_index('idx_dep_status', 'departures', ['status']) + op.create_index('idx_dep_booked_out_dt', 'departures', ['booked_out_dt']) + op.create_index('idx_dep_created_by', 'departures', ['created_by']) + + # Create arrivals table for non-PPR arrivals from elsewhere + op.create_table('arrivals', + 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=True), + sa.Column('callsign', sa.String(length=16), nullable=True), + sa.Column('pob', sa.Integer(), nullable=False), + sa.Column('in_from', sa.String(length=64), nullable=False), + sa.Column('status', sa.Enum('BOOKED_IN', 'LANDED', 'CANCELLED', name='arrivalsstatus'), nullable=False, server_default='BOOKED_IN'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('booked_in_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + 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' + ) + + op.create_index('idx_arr_registration', 'arrivals', ['registration']) + op.create_index('idx_arr_in_from', 'arrivals', ['in_from']) + op.create_index('idx_arr_status', 'arrivals', ['status']) + op.create_index('idx_arr_booked_in_dt', 'arrivals', ['booked_in_dt']) + op.create_index('idx_arr_created_by', 'arrivals', ['created_by']) def downgrade() -> None: """ - Drop the local_flights table. + Drop the local_flights, departures, and arrivals tables. """ + op.drop_table('arrivals') + op.drop_table('departures') op.drop_table('local_flights') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 439e869..8021ec3 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -1,11 +1,13 @@ from fastapi import APIRouter -from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights +from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals 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(departures.router, prefix="/departures", tags=["departures"]) +api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"]) 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/arrivals.py b/backend/app/api/endpoints/arrivals.py new file mode 100644 index 0000000..c625004 --- /dev/null +++ b/backend/app/api/endpoints/arrivals.py @@ -0,0 +1,167 @@ +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_arrival import arrival as crud_arrival +from app.schemas.arrival import Arrival, ArrivalCreate, ArrivalUpdate, ArrivalStatus, ArrivalStatusUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[Arrival]) +async def get_arrivals( + request: Request, + skip: int = 0, + limit: int = 100, + status: Optional[ArrivalStatus] = 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 arrival records with optional filtering""" + arrivals = crud_arrival.get_multi( + db, skip=skip, limit=limit, status=status, + date_from=date_from, date_to=date_to + ) + return arrivals + + +@router.post("/", response_model=Arrival) +async def create_arrival( + request: Request, + arrival_in: ArrivalCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Create a new arrival record""" + arrival = crud_arrival.create(db, obj_in=arrival_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": "arrival_booked_in", + "data": { + "id": arrival.id, + "registration": arrival.registration, + "in_from": arrival.in_from, + "status": arrival.status.value + } + }) + + return arrival + + +@router.get("/{arrival_id}", response_model=Arrival) +async def get_arrival( + arrival_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific arrival record""" + arrival = crud_arrival.get(db, arrival_id=arrival_id) + if not arrival: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Arrival record not found" + ) + return arrival + + +@router.put("/{arrival_id}", response_model=Arrival) +async def update_arrival( + request: Request, + arrival_id: int, + arrival_in: ArrivalUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update an arrival record""" + db_arrival = crud_arrival.get(db, arrival_id=arrival_id) + if not db_arrival: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Arrival record not found" + ) + + arrival = crud_arrival.update(db, db_obj=db_arrival, obj_in=arrival_in) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "arrival_updated", + "data": { + "id": arrival.id, + "registration": arrival.registration, + "status": arrival.status.value + } + }) + + return arrival + + +@router.patch("/{arrival_id}/status", response_model=Arrival) +async def update_arrival_status( + request: Request, + arrival_id: int, + status_update: ArrivalStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update arrival status""" + arrival = crud_arrival.update_status( + db, + arrival_id=arrival_id, + status=status_update.status, + timestamp=status_update.timestamp + ) + if not arrival: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Arrival record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "arrival_status_update", + "data": { + "id": arrival.id, + "registration": arrival.registration, + "status": arrival.status.value, + "landed_dt": arrival.landed_dt.isoformat() if arrival.landed_dt else None + } + }) + + return arrival + + +@router.delete("/{arrival_id}", response_model=Arrival) +async def cancel_arrival( + request: Request, + arrival_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Cancel an arrival record""" + arrival = crud_arrival.cancel(db, arrival_id=arrival_id) + if not arrival: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Arrival record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "arrival_cancelled", + "data": { + "id": arrival.id, + "registration": arrival.registration + } + }) + + return arrival diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py new file mode 100644 index 0000000..8d46197 --- /dev/null +++ b/backend/app/api/endpoints/departures.py @@ -0,0 +1,167 @@ +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_departure import departure as crud_departure +from app.schemas.departure import Departure, DepartureCreate, DepartureUpdate, DepartureStatus, DepartureStatusUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[Departure]) +async def get_departures( + request: Request, + skip: int = 0, + limit: int = 100, + status: Optional[DepartureStatus] = 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 departure records with optional filtering""" + departures = crud_departure.get_multi( + db, skip=skip, limit=limit, status=status, + date_from=date_from, date_to=date_to + ) + return departures + + +@router.post("/", response_model=Departure) +async def create_departure( + request: Request, + departure_in: DepartureCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Create a new departure record""" + departure = crud_departure.create(db, obj_in=departure_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": "departure_booked_out", + "data": { + "id": departure.id, + "registration": departure.registration, + "out_to": departure.out_to, + "status": departure.status.value + } + }) + + return departure + + +@router.get("/{departure_id}", response_model=Departure) +async def get_departure( + departure_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific departure record""" + departure = crud_departure.get(db, departure_id=departure_id) + if not departure: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Departure record not found" + ) + return departure + + +@router.put("/{departure_id}", response_model=Departure) +async def update_departure( + request: Request, + departure_id: int, + departure_in: DepartureUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update a departure record""" + db_departure = crud_departure.get(db, departure_id=departure_id) + if not db_departure: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Departure record not found" + ) + + departure = crud_departure.update(db, db_obj=db_departure, obj_in=departure_in) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "departure_updated", + "data": { + "id": departure.id, + "registration": departure.registration, + "status": departure.status.value + } + }) + + return departure + + +@router.patch("/{departure_id}/status", response_model=Departure) +async def update_departure_status( + request: Request, + departure_id: int, + status_update: DepartureStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update departure status""" + departure = crud_departure.update_status( + db, + departure_id=departure_id, + status=status_update.status, + timestamp=status_update.timestamp + ) + if not departure: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Departure record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "departure_status_update", + "data": { + "id": departure.id, + "registration": departure.registration, + "status": departure.status.value, + "departure_dt": departure.departure_dt.isoformat() if departure.departure_dt else None + } + }) + + return departure + + +@router.delete("/{departure_id}", response_model=Departure) +async def cancel_departure( + request: Request, + departure_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Cancel a departure record""" + departure = crud_departure.cancel(db, departure_id=departure_id) + if not departure: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Departure record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "departure_cancelled", + "data": { + "id": departure.id, + "registration": departure.registration + } + }) + + return departure diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index 5d08c05..e7b30f6 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -4,8 +4,12 @@ from sqlalchemy.orm import Session from app.api.deps import get_db from app.crud.crud_ppr import ppr as crud_ppr from app.crud.crud_local_flight import local_flight as crud_local_flight +from app.crud.crud_departure import departure as crud_departure +from app.crud.crud_arrival import arrival as crud_arrival from app.schemas.ppr import PPRPublic from app.models.local_flight import LocalFlightStatus +from app.models.departure import DepartureStatus +from app.models.arrival import ArrivalStatus from datetime import date router = APIRouter() @@ -56,7 +60,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)): @router.get("/departures") async def get_public_departures(db: Session = Depends(get_db)): - """Get today's departures for public display (PPR and local flights)""" + """Get today's departures for public display (PPR, local flights, and departures to other airports)""" departures = crud_ppr.get_departures_today(db) # Convert PPR departures to dictionaries @@ -70,7 +74,8 @@ async def get_public_departures(db: Session = Depends(get_db)): 'etd': departure.etd, 'departed_dt': departure.departed_dt, 'status': departure.status.value, - 'isLocalFlight': False + 'isLocalFlight': False, + 'isDeparture': False }) # Add local flights with BOOKED_OUT status @@ -91,7 +96,29 @@ async def get_public_departures(db: Session = Depends(get_db)): 'departed_dt': None, 'status': 'BOOKED_OUT', 'isLocalFlight': True, - 'flight_type': flight.flight_type.value + 'flight_type': flight.flight_type.value, + 'isDeparture': False + }) + + # Add departures to other airports with BOOKED_OUT status + departures_to_airports = crud_departure.get_multi( + db, + status=DepartureStatus.BOOKED_OUT, + limit=1000 + ) + + # Convert departures to match the format for display + for dep in departures_to_airports: + departures_list.append({ + 'ac_call': dep.callsign or dep.registration, + 'ac_reg': dep.registration, + 'ac_type': dep.type, + 'out_to': dep.out_to, + 'etd': dep.booked_out_dt, + 'departed_dt': None, + 'status': 'BOOKED_OUT', + 'isLocalFlight': False, + 'isDeparture': True }) return departures_list \ No newline at end of file diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py new file mode 100644 index 0000000..202aa4a --- /dev/null +++ b/backend/app/crud/crud_arrival.py @@ -0,0 +1,104 @@ +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.arrival import Arrival, ArrivalStatus +from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate + + +class CRUDArrival: + def get(self, db: Session, arrival_id: int) -> Optional[Arrival]: + return db.query(Arrival).filter(Arrival.id == arrival_id).first() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[ArrivalStatus] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None + ) -> List[Arrival]: + query = db.query(Arrival) + + if status: + query = query.filter(Arrival.status == status) + + if date_from: + query = query.filter(func.date(Arrival.booked_in_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Arrival.booked_in_dt) <= date_to) + + return query.order_by(desc(Arrival.booked_in_dt)).offset(skip).limit(limit).all() + + def get_arrivals_today(self, db: Session) -> List[Arrival]: + """Get today's arrivals (booked in or landed)""" + today = date.today() + return db.query(Arrival).filter( + and_( + func.date(Arrival.booked_in_dt) == today, + or_( + Arrival.status == ArrivalStatus.BOOKED_IN, + Arrival.status == ArrivalStatus.LANDED + ) + ) + ).order_by(Arrival.booked_in_dt).all() + + def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival: + db_obj = Arrival( + **obj_in.dict(), + created_by=created_by, + status=ArrivalStatus.BOOKED_IN + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate) -> Arrival: + 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, + arrival_id: int, + status: ArrivalStatus, + timestamp: Optional[datetime] = None + ) -> Optional[Arrival]: + db_obj = self.get(db, arrival_id) + if not db_obj: + return None + + db_obj.status = status + + if status == ArrivalStatus.LANDED and timestamp: + db_obj.landed_dt = timestamp + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]: + db_obj = self.get(db, arrival_id) + if not db_obj: + return None + + db_obj.status = ArrivalStatus.CANCELLED + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +arrival = CRUDArrival() diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py new file mode 100644 index 0000000..015a09f --- /dev/null +++ b/backend/app/crud/crud_departure.py @@ -0,0 +1,104 @@ +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.departure import Departure, DepartureStatus +from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate + + +class CRUDDeparture: + def get(self, db: Session, departure_id: int) -> Optional[Departure]: + return db.query(Departure).filter(Departure.id == departure_id).first() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[DepartureStatus] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None + ) -> List[Departure]: + query = db.query(Departure) + + if status: + query = query.filter(Departure.status == status) + + if date_from: + query = query.filter(func.date(Departure.booked_out_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Departure.booked_out_dt) <= date_to) + + return query.order_by(desc(Departure.booked_out_dt)).offset(skip).limit(limit).all() + + def get_departures_today(self, db: Session) -> List[Departure]: + """Get today's departures (booked out or departed)""" + today = date.today() + return db.query(Departure).filter( + and_( + func.date(Departure.booked_out_dt) == today, + or_( + Departure.status == DepartureStatus.BOOKED_OUT, + Departure.status == DepartureStatus.DEPARTED + ) + ) + ).order_by(Departure.booked_out_dt).all() + + def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure: + db_obj = Departure( + **obj_in.dict(), + created_by=created_by, + status=DepartureStatus.BOOKED_OUT + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate) -> Departure: + 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, + departure_id: int, + status: DepartureStatus, + timestamp: Optional[datetime] = None + ) -> Optional[Departure]: + db_obj = self.get(db, departure_id) + if not db_obj: + return None + + db_obj.status = status + + if status == DepartureStatus.DEPARTED and timestamp: + db_obj.departure_dt = timestamp + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def cancel(self, db: Session, departure_id: int) -> Optional[Departure]: + db_obj = self.get(db, departure_id) + if not db_obj: + return None + + db_obj.status = DepartureStatus.CANCELLED + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +departure = CRUDDeparture() diff --git a/backend/app/main.py b/backend/app/main.py index 787eaea..dad5715 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,8 @@ 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 +from app.models.departure import Departure +from app.models.arrival import Arrival # Set up logging logging.basicConfig(level=logging.INFO) diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py new file mode 100644 index 0000000..a2e2930 --- /dev/null +++ b/backend/app/models/arrival.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func +from sqlalchemy.ext.declarative import declarative_base +from enum import Enum +from datetime import datetime + +Base = declarative_base() + + +class ArrivalStatus(str, Enum): + BOOKED_IN = "BOOKED_IN" + LANDED = "LANDED" + CANCELLED = "CANCELLED" + + +class Arrival(Base): + __tablename__ = "arrivals" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + registration = Column(String(16), nullable=False, index=True) + type = Column(String(32), nullable=True) + callsign = Column(String(16), nullable=True) + pob = Column(Integer, nullable=False) + in_from = Column(String(4), nullable=False, index=True) + status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True) + notes = Column(Text, nullable=True) + booked_in_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) + landed_dt = Column(DateTime, nullable=True) + created_by = Column(String(16), nullable=True, index=True) + 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 new file mode 100644 index 0000000..9f4132b --- /dev/null +++ b/backend/app/models/departure.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func +from sqlalchemy.ext.declarative import declarative_base +from enum import Enum +from datetime import datetime + +Base = declarative_base() + + +class DepartureStatus(str, Enum): + BOOKED_OUT = "BOOKED_OUT" + DEPARTED = "DEPARTED" + CANCELLED = "CANCELLED" + + +class Departure(Base): + __tablename__ = "departures" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + registration = Column(String(16), nullable=False, index=True) + type = Column(String(32), nullable=True) + callsign = Column(String(16), nullable=True) + pob = Column(Integer, nullable=False) + out_to = Column(String(4), nullable=False, index=True) + status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True) + notes = Column(Text, nullable=True) + booked_out_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) + departure_dt = Column(DateTime, nullable=True) + created_by = Column(String(16), nullable=True, index=True) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py new file mode 100644 index 0000000..7081d05 --- /dev/null +++ b/backend/app/schemas/arrival.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from enum import Enum + + +class ArrivalStatus(str, Enum): + BOOKED_IN = "BOOKED_IN" + LANDED = "LANDED" + CANCELLED = "CANCELLED" + + +class ArrivalBase(BaseModel): + registration: str + type: Optional[str] = None + callsign: Optional[str] = None + pob: int + in_from: str + 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('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 + + +class ArrivalCreate(ArrivalBase): + pass + + +class ArrivalUpdate(BaseModel): + type: Optional[str] = None + callsign: Optional[str] = None + pob: Optional[int] = None + in_from: Optional[str] = None + notes: Optional[str] = None + + +class ArrivalStatusUpdate(BaseModel): + status: ArrivalStatus + timestamp: Optional[datetime] = None + + +class Arrival(ArrivalBase): + id: int + status: ArrivalStatus + booked_in_dt: datetime + landed_dt: Optional[datetime] = None + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/departure.py b/backend/app/schemas/departure.py new file mode 100644 index 0000000..710fb45 --- /dev/null +++ b/backend/app/schemas/departure.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from enum import Enum + + +class DepartureStatus(str, Enum): + BOOKED_OUT = "BOOKED_OUT" + DEPARTED = "DEPARTED" + CANCELLED = "CANCELLED" + + +class DepartureBase(BaseModel): + registration: str + type: Optional[str] = None + callsign: Optional[str] = None + pob: int + out_to: str + 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('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 + + +class DepartureCreate(DepartureBase): + pass + + +class DepartureUpdate(BaseModel): + type: Optional[str] = None + callsign: Optional[str] = None + pob: Optional[int] = None + out_to: Optional[str] = None + notes: Optional[str] = None + + +class DepartureStatusUpdate(BaseModel): + status: DepartureStatus + timestamp: Optional[datetime] = None + + +class Departure(DepartureBase): + id: int + status: DepartureStatus + booked_out_dt: datetime + departure_dt: Optional[datetime] = None + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py index 2fd1351..e5e1e59 100644 --- a/backend/app/schemas/local_flight.py +++ b/backend/app/schemas/local_flight.py @@ -19,7 +19,7 @@ class LocalFlightStatus(str, Enum): class LocalFlightBase(BaseModel): registration: str - type: str # Aircraft type + type: Optional[str] = None # Aircraft type - optional, can be looked up later callsign: Optional[str] = None pob: int flight_type: LocalFlightType @@ -31,11 +31,13 @@ class LocalFlightBase(BaseModel): raise ValueError('Aircraft registration is required') return v.strip().upper() - @validator('type') + @validator('type', pre=True, always=False) def validate_type(cls, v): - if not v or len(v.strip()) == 0: - raise ValueError('Aircraft type is required') - return v.strip() + if v is None or (isinstance(v, str) and len(v.strip()) == 0): + return None + if isinstance(v, str): + return v.strip() + return v @validator('pob') def validate_pob(cls, v): diff --git a/web/admin.html b/web/admin.html index d093fa5..6905d32 100644 --- a/web/admin.html +++ b/web/admin.html @@ -524,7 +524,7 @@ } /* Airport Lookup Styles */ - #arrival-airport-lookup-results, #departure-airport-lookup-results { + #arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results { margin-top: 0.5rem; padding: 0.5rem; background-color: #f8f9fa; @@ -986,12 +986,12 @@