diff --git a/.env.example b/.env.example index b88bbda..f08d18d 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ MAIL_FROM_NAME=your_mail_from_name_here # Application settings BASE_URL=your_base_url_here +# UI Configuration +TAG= +TOP_BAR_BASE_COLOR=#2c3e50 +ENVIRONMENT=development + # Redis (optional) REDIS_URL= diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 0267b04..1a2bd59 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from app.core.config import settings from app.db.session import Base # Import all models to ensure they are registered with Base -from app.models.ppr import PPRRecord, User, Journal, Airport, Aircraft +from app.models.ppr import PPRRecord, User, Airport, Aircraft # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py new file mode 100644 index 0000000..71911e5 --- /dev/null +++ b/backend/alembic/versions/002_local_flights.py @@ -0,0 +1,216 @@ +"""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. Also adds etd and renames booked_out_dt to created_dt, +and departure_dt to departed_dt for consistency. Transforms journal table from PPR-specific +to a generic polymorphic journal for all entity types. + +""" +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, departures, and arrivals tables. + Transform journal table from PPR-specific to generic polymorphic journal. + """ + + # Modify existing journal table to support all entity types + # First add new columns (check if they don't already exist) + from sqlalchemy import inspect, text + from alembic import op + + # Get table columns to check if entity_type and entity_id already exist + connection = op.get_context().bind + inspector = inspect(connection) + columns = [col['name'] for col in inspector.get_columns('journal')] + + if 'entity_type' not in columns: + op.add_column('journal', sa.Column('entity_type', sa.String(50), nullable=True)) + if 'entity_id' not in columns: + op.add_column('journal', sa.Column('entity_id', sa.BigInteger(), nullable=True)) + + # Migrate existing PPR journal entries: backfill entity_type and entity_id + op.execute(""" + UPDATE journal SET + entity_type = 'PPR', + entity_id = ppr_id + WHERE entity_type IS NULL AND ppr_id IS NOT NULL + """) + + # Make new columns NOT NULL after migration + op.alter_column('journal', 'entity_type', existing_type=sa.String(50), nullable=False) + op.alter_column('journal', 'entity_id', existing_type=sa.BigInteger(), nullable=False) + + # Make ip column nullable (new entries won't always have it) + op.alter_column('journal', 'ip', existing_type=sa.String(45), nullable=True) + + # Drop the foreign key constraint before dropping the column + if 'ppr_id' in columns: + op.drop_constraint('journal_ibfk_1', 'journal', type_='foreignkey') + op.drop_column('journal', 'ppr_id') + + # Add composite index for efficient queries + op.create_index('idx_entity_lookup', 'journal', ['entity_type', 'entity_id']) + + # Drop old index if it exists + try: + op.drop_index('idx_ppr_id', table_name='journal') + except: + pass + + 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=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), + sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'), + sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'), + sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('etd', sa.DateTime(), nullable=True), + sa.Column('departed_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 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_created_dt', 'local_flights', ['created_dt']) + op.create_index('idx_etd', 'local_flights', ['etd']) + 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('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('etd', sa.DateTime(), nullable=True), + sa.Column('departed_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_created_dt', 'departures', ['created_dt']) + op.create_index('idx_dep_etd', 'departures', ['etd']) + 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('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('eta', 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' + ) + + 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_created_dt', 'arrivals', ['created_dt']) + op.create_index('idx_arr_eta', 'arrivals', ['eta']) + op.create_index('idx_arr_created_by', 'arrivals', ['created_by']) + + # Create circuits table for tracking touch-and-go events during circuit training + op.create_table('circuits', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('local_flight_id', sa.BigInteger(), nullable=False), + sa.Column('circuit_timestamp', sa.DateTime(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['local_flight_id'], ['local_flights.id'], ondelete='CASCADE'), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + mysql_collate='utf8mb4_unicode_ci' + ) + + # Create indexes for circuits + op.create_index('idx_circuit_local_flight_id', 'circuits', ['local_flight_id']) + op.create_index('idx_circuit_timestamp', 'circuits', ['circuit_timestamp']) + + # Create overflights table for tracking aircraft talking to the tower but not departing/landing + op.create_table('overflights', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('registration', sa.String(length=16), nullable=False), + sa.Column('pob', sa.Integer(), nullable=True), + sa.Column('type', sa.String(length=32), nullable=True), + sa.Column('departure_airfield', sa.String(length=64), nullable=True), + sa.Column('destination_airfield', sa.String(length=64), nullable=True), + sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'CANCELLED', name='overflightstatus'), nullable=False, server_default='ACTIVE'), + sa.Column('call_dt', sa.DateTime(), nullable=False), + sa.Column('qsy_dt', sa.DateTime(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + 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 overflights + op.create_index('idx_ovf_registration', 'overflights', ['registration']) + op.create_index('idx_ovf_departure_airfield', 'overflights', ['departure_airfield']) + op.create_index('idx_ovf_destination_airfield', 'overflights', ['destination_airfield']) + op.create_index('idx_ovf_status', 'overflights', ['status']) + op.create_index('idx_ovf_call_dt', 'overflights', ['call_dt']) + op.create_index('idx_ovf_created_dt', 'overflights', ['created_dt']) + op.create_index('idx_ovf_created_by', 'overflights', ['created_by']) + + +def downgrade() -> None: + """ + Drop the overflights, circuits, arrivals, departures, and local_flights tables. + """ + op.drop_table('overflights') + op.drop_table('circuits') + 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 935b4fe..2a518c5 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -1,10 +1,16 @@ 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, departures, arrivals, circuits, journal, overflights 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(overflights.router, prefix="/overflights", tags=["overflights"]) +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(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..0937d4b --- /dev/null +++ b/backend/app/api/endpoints/arrivals.py @@ -0,0 +1,179 @@ +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" + ) + + # Get user IP from request + user_ip = request.client.host if request.client else None + + arrival = crud_arrival.update( + db, + db_obj=db_arrival, + obj_in=arrival_in, + user=current_user.username, + user_ip=user_ip + ) + + # 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""" + client_ip = get_client_ip(request) + arrival = crud_arrival.update_status( + db, + arrival_id=arrival_id, + status=status_update.status, + timestamp=status_update.timestamp, + user=current_user.username, + user_ip=client_ip + ) + 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/circuits.py b/backend/app/api/endpoints/circuits.py new file mode 100644 index 0000000..697bfae --- /dev/null +++ b/backend/app/api/endpoints/circuits.py @@ -0,0 +1,108 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from app.api.deps import get_db, get_current_read_user, get_current_operator_user +from app.crud.crud_circuit import crud_circuit +from app.schemas.circuit import Circuit, CircuitCreate, CircuitUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[Circuit]) +async def get_circuits( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get circuit records""" + circuits = crud_circuit.get_multi(db, skip=skip, limit=limit) + return circuits + + +@router.get("/flight/{local_flight_id}", response_model=List[Circuit]) +async def get_circuits_by_flight( + local_flight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get all circuits for a specific local flight""" + circuits = crud_circuit.get_by_local_flight(db, local_flight_id=local_flight_id) + return circuits + + +@router.post("/", response_model=Circuit) +async def create_circuit( + request: Request, + circuit_in: CircuitCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Record a new circuit (touch and go) for a local flight""" + circuit = crud_circuit.create(db, obj_in=circuit_in) + + # 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() + } + }) + + return circuit + + +@router.get("/{circuit_id}", response_model=Circuit) +async def get_circuit( + circuit_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific circuit record""" + circuit = crud_circuit.get(db, circuit_id=circuit_id) + if not circuit: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Circuit record not found" + ) + return circuit + + +@router.put("/{circuit_id}", response_model=Circuit) +async def update_circuit( + circuit_id: int, + circuit_in: CircuitUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update a circuit record""" + circuit = crud_circuit.get(db, circuit_id=circuit_id) + if not circuit: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Circuit record not found" + ) + circuit = crud_circuit.update(db, db_obj=circuit, obj_in=circuit_in) + return circuit + + +@router.delete("/{circuit_id}") +async def delete_circuit( + circuit_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Delete a circuit record""" + circuit = crud_circuit.get(db, circuit_id=circuit_id) + if not circuit: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Circuit record not found" + ) + crud_circuit.delete(db, circuit_id=circuit_id) + return {"detail": "Circuit record deleted"} diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py new file mode 100644 index 0000000..f5d8a2a --- /dev/null +++ b/backend/app/api/endpoints/departures.py @@ -0,0 +1,179 @@ +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" + ) + + # Get user IP from request + user_ip = request.client.host if request.client else None + + departure = crud_departure.update( + db, + db_obj=db_departure, + obj_in=departure_in, + user=current_user.username, + user_ip=user_ip + ) + + # 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""" + client_ip = get_client_ip(request) + departure = crud_departure.update_status( + db, + departure_id=departure_id, + status=status_update.status, + timestamp=status_update.timestamp, + user=current_user.username, + user_ip=client_ip + ) + 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, + "departed_dt": departure.departed_dt.isoformat() if departure.departed_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/journal.py b/backend/app/api/endpoints/journal.py new file mode 100644 index 0000000..b409e11 --- /dev/null +++ b/backend/app/api/endpoints/journal.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.api import deps +from app.crud.crud_journal import journal +from app.models.journal import EntityType +from app.schemas.journal import JournalEntryResponse, EntityJournalResponse +from typing import List + +router = APIRouter(tags=["journal"]) + + +@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse) +async def get_entity_journal( + entity_type: str, + entity_id: int, + limit: int = 100, + db: Session = Depends(deps.get_db), + current_user = Depends(deps.get_current_user) +): + """ + Get journal entries for a specific entity (PPR, LOCAL_FLIGHT, ARRIVAL, or DEPARTURE). + + The journal is immutable - entries are created automatically by the backend + when changes are made. This endpoint is read-only. + + Parameters: + - entity_type: One of 'PPR', 'LOCAL_FLIGHT', 'ARRIVAL', 'DEPARTURE' + - entity_id: The ID of the entity + - limit: Maximum number of entries to return (default 100) + """ + # Validate entity type + try: + entity = EntityType[entity_type.upper()] + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}" + ) + + entries = journal.get_entity_journal(db, entity, entity_id, limit=limit) + + return EntityJournalResponse( + entity_type=entity_type, + entity_id=entity_id, + entries=entries, + total_entries=len(entries) + ) + + +@router.get("/user/{username}", response_model=List[JournalEntryResponse]) +async def get_user_journal( + username: str, + limit: int = 100, + db: Session = Depends(deps.get_db), + current_user = Depends(deps.get_current_user) +): + """ + Get all journal entries created by a specific user. + + This endpoint is read-only and returns entries in reverse chronological order. + """ + entries = journal.get_user_journal(db, username, limit=limit) + return entries diff --git a/backend/app/api/endpoints/local_flights.py b/backend/app/api/endpoints/local_flights.py new file mode 100644 index 0000000..78dcd78 --- /dev/null +++ b/backend/app/api/endpoints/local_flights.py @@ -0,0 +1,207 @@ +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" + ) + + # Get user IP from request + user_ip = request.client.host if request.client else None + + flight = crud_local_flight.update( + db, + db_obj=db_flight, + obj_in=flight_in, + user=current_user.username, + user_ip=user_ip + ) + + # 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.)""" + client_ip = get_client_ip(request) + flight = crud_local_flight.update_status( + db, + flight_id=flight_id, + status=status_update.status, + timestamp=status_update.timestamp, + user=current_user.username, + user_ip=client_ip + ) + 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/api/endpoints/overflights.py b/backend/app/api/endpoints/overflights.py new file mode 100644 index 0000000..fe20c5a --- /dev/null +++ b/backend/app/api/endpoints/overflights.py @@ -0,0 +1,206 @@ +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_overflight import overflight as crud_overflight +from app.schemas.overflight import Overflight, OverflightCreate, OverflightUpdate, OverflightStatus, OverflightStatusUpdate +from app.models.ppr import User +from app.core.utils import get_client_ip + +router = APIRouter() + + +@router.get("/", response_model=List[Overflight]) +async def get_overflights( + request: Request, + skip: int = 0, + limit: int = 100, + status: Optional[OverflightStatus] = 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 overflight records with optional filtering""" + overflights = crud_overflight.get_multi( + db, skip=skip, limit=limit, status=status, + date_from=date_from, date_to=date_to + ) + return overflights + + +@router.post("/", response_model=Overflight) +async def create_overflight( + request: Request, + overflight_in: OverflightCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Create a new overflight record""" + overflight = crud_overflight.create(db, obj_in=overflight_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": "overflight_created", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "departure_airfield": overflight.departure_airfield, + "destination_airfield": overflight.destination_airfield, + "status": overflight.status.value + } + }) + + return overflight + + +@router.get("/{overflight_id}", response_model=Overflight) +async def get_overflight( + overflight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get a specific overflight record""" + overflight = crud_overflight.get(db, overflight_id=overflight_id) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + return overflight + + +@router.put("/{overflight_id}", response_model=Overflight) +async def update_overflight( + request: Request, + overflight_id: int, + overflight_in: OverflightUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update an overflight record""" + db_overflight = crud_overflight.get(db, overflight_id=overflight_id) + if not db_overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Get user IP from request + user_ip = request.client.host if request.client else None + + overflight = crud_overflight.update( + db, + db_obj=db_overflight, + obj_in=overflight_in, + user=current_user.username, + user_ip=user_ip + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_updated", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "status": overflight.status.value + } + }) + + return overflight + + +@router.patch("/{overflight_id}/status", response_model=Overflight) +async def update_overflight_status( + request: Request, + overflight_id: int, + status_update: OverflightStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Update overflight status (ACTIVE -> INACTIVE for QSY)""" + client_ip = get_client_ip(request) + overflight = crud_overflight.update_status( + db, + overflight_id=overflight_id, + status=status_update.status, + timestamp=status_update.qsy_dt, + user=current_user.username, + user_ip=client_ip + ) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_status_update", + "data": { + "id": overflight.id, + "registration": overflight.registration, + "status": overflight.status.value, + "qsy_dt": overflight.qsy_dt.isoformat() if overflight.qsy_dt else None + } + }) + + return overflight + + +@router.delete("/{overflight_id}", response_model=Overflight) +async def cancel_overflight( + request: Request, + overflight_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Cancel an overflight record""" + client_ip = get_client_ip(request) + overflight = crud_overflight.cancel( + db, + overflight_id=overflight_id, + user=current_user.username, + user_ip=client_ip + ) + if not overflight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Overflight record not found" + ) + + # Send real-time update + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "overflight_cancelled", + "data": { + "id": overflight.id, + "registration": overflight.registration + } + }) + + return overflight + + +@router.get("/active/list", response_model=List[Overflight]) +async def get_active_overflights( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get currently active overflights""" + overflights = crud_overflight.get_active_overflights(db) + return overflights + + +@router.get("/today/list", response_model=List[Overflight]) +async def get_overflights_today( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_read_user) +): + """Get today's overflights""" + overflights = crud_overflight.get_overflights_today(db) + return overflights diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index f92f209..2877eed 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -3,20 +3,240 @@ from fastapi import APIRouter, Depends 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, datetime, timedelta +import re router = APIRouter() -@router.get("/arrivals", response_model=List[PPRPublic]) +def lighten_color(hex_color, factor=0.3): + """Lighten a hex color by a factor (0-1)""" + hex_color = hex_color.lstrip('#') + if len(hex_color) != 6: + return hex_color # Invalid, return as is + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + r = min(255, int(r + (255 - r) * factor)) + g = min(255, int(g + (255 - g) * factor)) + b = min(255, int(b + (255 - b) * factor)) + return f"#{r:02x}{g:02x}{b:02x}" + + +def darken_color(hex_color, factor=0.3): + """Darken a hex color by a factor (0-1)""" + hex_color = hex_color.lstrip('#') + if len(hex_color) != 6: + return hex_color # Invalid, return as is + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + r = max(0, int(r * (1 - factor))) + g = max(0, int(g * (1 - factor))) + b = max(0, int(b * (1 - factor))) + return f"#{r:02x}{g:02x}{b:02x}" + + +@router.get("/arrivals") async def get_public_arrivals(db: Session = Depends(get_db)): - """Get today's arrivals for public display""" + """Get today's arrivals for public display (PPR and local flights)""" arrivals = crud_ppr.get_arrivals_today(db) - return arrivals + + # Convert PPR arrivals to dictionaries + arrivals_list = [] + for arrival in arrivals: + arrivals_list.append({ + 'ac_call': arrival.ac_call, + 'ac_reg': arrival.ac_reg, + 'ac_type': arrival.ac_type, + 'in_from': arrival.in_from, + 'eta': arrival.eta, + 'landed_dt': arrival.landed_dt, + 'status': arrival.status.value, + 'isLocalFlight': False + }) + + # Add local flights with DEPARTED status that were booked out today + local_flights = crud_local_flight.get_multi( + db, + status=LocalFlightStatus.DEPARTED, + limit=1000 + ) + + # Get today's date boundaries + today = date.today() + today_start = datetime.combine(today, datetime.min.time()) + today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()) + + # Convert local flights to match the PPR format for display + for flight in local_flights: + # Only include flights booked out today + if not (today_start <= flight.created_dt < today_end): + continue + + # Calculate ETA from departed_dt + duration (if both are available) + eta = flight.departed_dt + if flight.departed_dt and flight.duration: + eta = flight.departed_dt + timedelta(minutes=flight.duration) + + arrivals_list.append({ + 'ac_call': flight.callsign or flight.registration, + 'ac_reg': flight.registration, + 'ac_type': flight.type, + 'in_from': None, + 'eta': eta, + 'landed_dt': None, + 'status': 'DEPARTED', + 'isLocalFlight': True, + 'flight_type': flight.flight_type.value + }) + + # Add booked-in arrivals + booked_in_arrivals = crud_arrival.get_multi(db, limit=1000) + for arrival in booked_in_arrivals: + # Only include BOOKED_IN and LANDED arrivals + if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED): + continue + # For BOOKED_IN, only include those created today + if arrival.status == ArrivalStatus.BOOKED_IN: + if not (today_start <= arrival.created_dt < today_end): + continue + # For LANDED, only include those landed today + elif arrival.status == ArrivalStatus.LANDED: + if not arrival.landed_dt or not (today_start <= arrival.landed_dt < today_end): + continue + + arrivals_list.append({ + 'registration': arrival.registration, + 'callsign': arrival.callsign, + 'type': arrival.type, + 'in_from': arrival.in_from, + 'eta': arrival.eta, + 'landed_dt': arrival.landed_dt, + 'status': arrival.status.value, + 'isBookedIn': True + }) + + return arrivals_list -@router.get("/departures", response_model=List[PPRPublic]) +@router.get("/departures") async def get_public_departures(db: Session = Depends(get_db)): - """Get today's departures for public display""" + """Get today's departures for public display (PPR, local flights, and departures to other airports)""" departures = crud_ppr.get_departures_today(db) - return departures \ No newline at end of file + + # Convert PPR departures to dictionaries + departures_list = [] + for departure in departures: + departures_list.append({ + 'ac_call': departure.ac_call, + 'ac_reg': departure.ac_reg, + 'ac_type': departure.ac_type, + 'out_to': departure.out_to, + 'etd': departure.etd, + 'departed_dt': departure.departed_dt, + 'status': departure.status.value, + 'isLocalFlight': False, + 'isDeparture': False + }) + + # Add local flights with BOOKED_OUT status that were booked out today + local_flights = crud_local_flight.get_multi( + db, + status=LocalFlightStatus.BOOKED_OUT, + limit=1000 + ) + + # Get today's date boundaries + today = date.today() + today_start = datetime.combine(today, datetime.min.time()) + today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()) + + # Convert local flights to match the PPR format for display + for flight in local_flights: + # Only include flights booked out today + if not (today_start <= flight.created_dt < today_end): + continue + departures_list.append({ + 'ac_call': flight.callsign or flight.registration, + 'ac_reg': flight.registration, + 'ac_type': flight.type, + 'out_to': None, + 'etd': flight.etd or flight.created_dt, + 'departed_dt': None, + 'status': 'BOOKED_OUT', + 'isLocalFlight': True, + '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 + ) + + # Get today's date boundaries + today = date.today() + today_start = datetime.combine(today, datetime.min.time()) + today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()) + + # Convert departures to match the format for display + for dep in departures_to_airports: + # Only include departures booked out today + if not (today_start <= dep.created_dt < today_end): + continue + 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.etd or dep.created_dt, + 'departed_dt': None, + 'status': 'BOOKED_OUT', + 'isLocalFlight': False, + 'isDeparture': True + }) + + # Add departures to other airports with DEPARTED status (taken off today) + departed_to_airports = crud_departure.get_multi( + db, + status=DepartureStatus.DEPARTED, + limit=1000 + ) + + for dep in departed_to_airports: + # Only include departures that departed today + if not dep.departed_dt or not (today_start <= dep.departed_dt < today_end): + continue + 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.etd or dep.created_dt, + 'departed_dt': dep.departed_dt, + 'status': 'DEPARTED', + 'isLocalFlight': False, + 'isDeparture': True + }) + + return departures_list + + +@router.get("/config") +async def get_ui_config(): + """Get UI configuration for client-side rendering""" + from app.core.config import settings + base_color = settings.top_bar_base_color + return { + "tag": settings.tag, + "top_bar_gradient_start": base_color, + "top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end + "footer_color": darken_color(base_color, 0.2), # Darken for footer + "environment": settings.environment + } \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3c4741b..97dcca1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -28,6 +28,11 @@ class Settings(BaseSettings): project_name: str = "Airfield PPR API" base_url: str + # UI Configuration + tag: str = "" + top_bar_base_color: str = "#2c3e50" + environment: str = "production" # production, development, staging, etc. + # Redis settings (for future use) redis_url: Optional[str] = None diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py new file mode 100644 index 0000000..1e5e9dc --- /dev/null +++ b/backend/app/crud/crud_arrival.py @@ -0,0 +1,147 @@ +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 +from app.models.journal import EntityType +from app.crud.crud_journal import journal + + +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.created_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Arrival.created_dt) <= date_to) + + return query.order_by(desc(Arrival.created_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.created_dt) == today, + or_( + Arrival.status == ArrivalStatus.BOOKED_IN, + Arrival.status == ArrivalStatus.LANDED + ) + ) + ).order_by(Arrival.created_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, user: str = "system", user_ip: Optional[str] = None) -> Arrival: + from datetime import datetime as dt + + update_data = obj_in.dict(exclude_unset=True) + changes = [] + + for field, value in update_data.items(): + old_value = getattr(db_obj, field) + + # Normalize datetime values for comparison (ignore timezone differences) + if isinstance(old_value, dt) and isinstance(value, dt): + # Compare only the date and time, ignoring timezone + old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value + new_normalized = value.replace(tzinfo=None) if value.tzinfo else value + if old_normalized == new_normalized: + continue # Skip if datetimes are the same + + if old_value != value: + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) + + if changes: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log changes in journal + for change in changes: + journal.log_change( + db, + EntityType.ARRIVAL, + db_obj.id, + change, + user, + user_ip + ) + + return db_obj + + def update_status( + self, + db: Session, + arrival_id: int, + status: ArrivalStatus, + timestamp: Optional[datetime] = None, + user: str = "system", + user_ip: Optional[str] = None + ) -> Optional[Arrival]: + db_obj = self.get(db, arrival_id) + if not db_obj: + return None + + old_status = db_obj.status + 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) + + # Log status change in journal + journal.log_change( + db, + EntityType.ARRIVAL, + arrival_id, + f"Status changed from {old_status.value} to {status.value}", + user, + user_ip + ) + + 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_circuit.py b/backend/app/crud/crud_circuit.py new file mode 100644 index 0000000..90bccf0 --- /dev/null +++ b/backend/app/crud/crud_circuit.py @@ -0,0 +1,55 @@ +from typing import List, Optional +from sqlalchemy.orm import Session +from sqlalchemy import desc +from datetime import datetime +from app.models.circuit import Circuit +from app.schemas.circuit import CircuitCreate, CircuitUpdate + + +class CRUDCircuit: + def get(self, db: Session, circuit_id: int) -> Optional[Circuit]: + return db.query(Circuit).filter(Circuit.id == circuit_id).first() + + def get_by_local_flight(self, db: Session, local_flight_id: int) -> List[Circuit]: + """Get all circuits for a specific local flight""" + return db.query(Circuit).filter( + Circuit.local_flight_id == local_flight_id + ).order_by(Circuit.circuit_timestamp).all() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100 + ) -> List[Circuit]: + return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all() + + def create(self, db: Session, obj_in: CircuitCreate) -> Circuit: + db_obj = Circuit( + local_flight_id=obj_in.local_flight_id, + circuit_timestamp=obj_in.circuit_timestamp + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit: + obj_data = obj_in.dict(exclude_unset=True) + for field, value in obj_data.items(): + setattr(db_obj, field, value) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def delete(self, db: Session, circuit_id: int) -> bool: + circuit = self.get(db, circuit_id) + if circuit: + db.delete(circuit) + db.commit() + return True + return False + + +crud_circuit = CRUDCircuit() diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py new file mode 100644 index 0000000..a6fd2c1 --- /dev/null +++ b/backend/app/crud/crud_departure.py @@ -0,0 +1,147 @@ +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 +from app.models.journal import EntityType +from app.crud.crud_journal import journal + + +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.created_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Departure.created_dt) <= date_to) + + return query.order_by(desc(Departure.created_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.created_dt) == today, + or_( + Departure.status == DepartureStatus.BOOKED_OUT, + Departure.status == DepartureStatus.DEPARTED + ) + ) + ).order_by(Departure.created_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, user: str = "system", user_ip: Optional[str] = None) -> Departure: + from datetime import datetime as dt + + update_data = obj_in.dict(exclude_unset=True) + changes = [] + + for field, value in update_data.items(): + old_value = getattr(db_obj, field) + + # Normalize datetime values for comparison (ignore timezone differences) + if isinstance(old_value, dt) and isinstance(value, dt): + # Compare only the date and time, ignoring timezone + old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value + new_normalized = value.replace(tzinfo=None) if value.tzinfo else value + if old_normalized == new_normalized: + continue # Skip if datetimes are the same + + if old_value != value: + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) + + if changes: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log changes in journal + for change in changes: + journal.log_change( + db, + EntityType.DEPARTURE, + db_obj.id, + change, + user, + user_ip + ) + + return db_obj + + def update_status( + self, + db: Session, + departure_id: int, + status: DepartureStatus, + timestamp: Optional[datetime] = None, + user: str = "system", + user_ip: Optional[str] = None + ) -> Optional[Departure]: + db_obj = self.get(db, departure_id) + if not db_obj: + return None + + old_status = db_obj.status + db_obj.status = status + + if status == DepartureStatus.DEPARTED and timestamp: + db_obj.departed_dt = timestamp + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log status change in journal + journal.log_change( + db, + EntityType.DEPARTURE, + departure_id, + f"Status changed from {old_status.value} to {status.value}", + user, + user_ip + ) + + 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/crud/crud_journal.py b/backend/app/crud/crud_journal.py index 669527f..9d8c6d6 100644 --- a/backend/app/crud/crud_journal.py +++ b/backend/app/crud/crud_journal.py @@ -1,35 +1,95 @@ -from typing import List +from typing import List, Optional from sqlalchemy.orm import Session -from app.models.ppr import Journal -from app.schemas.ppr import JournalCreate +from app.models.journal import JournalEntry, EntityType +from datetime import datetime class CRUDJournal: - def create(self, db: Session, obj_in: JournalCreate) -> Journal: - db_obj = Journal(**obj_in.dict()) - db.add(db_obj) - db.commit() - db.refresh(db_obj) - return db_obj - - def get_by_ppr_id(self, db: Session, ppr_id: int) -> List[Journal]: - return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all() - + """CRUD operations for the generic journal table. + + This journal is immutable - entries can only be created (by backend) and queried. + There are no API endpoints for creating journal entries; the backend logs changes directly. + """ + def log_change( self, db: Session, - ppr_id: int, + entity_type: EntityType, + entity_id: int, entry: str, - user: str, - ip: str - ) -> Journal: - journal_in = JournalCreate( - ppr_id=ppr_id, + user: str, + ip: Optional[str] = None + ) -> JournalEntry: + """Log a change to an entity. Internal backend use only.""" + journal_entry = JournalEntry( + entity_type=entity_type.value, + entity_id=entity_id, + entry=entry, + user=user, + ip=ip, + entry_dt=datetime.utcnow() + ) + db.add(journal_entry) + db.commit() + db.refresh(journal_entry) + return journal_entry + + def get_entity_journal( + self, + db: Session, + entity_type: EntityType, + entity_id: int, + limit: int = 100 + ) -> List[JournalEntry]: + """Get all journal entries for a specific entity. Read-only API endpoint.""" + return db.query(JournalEntry).filter( + JournalEntry.entity_type == entity_type.value, + JournalEntry.entity_id == entity_id + ).order_by(JournalEntry.entry_dt.desc()).limit(limit).all() + + def get_user_journal( + self, + db: Session, + user: str, + limit: int = 100 + ) -> List[JournalEntry]: + """Get all journal entries created by a specific user.""" + return db.query(JournalEntry).filter( + JournalEntry.user == user + ).order_by(JournalEntry.entry_dt.desc()).limit(limit).all() + + # Convenience methods for backward compatibility with PPR journal + def log_ppr_change( + self, + db: Session, + ppr_id: int, + entry: str, + user: str, + ip: Optional[str] = None + ) -> JournalEntry: + """Log a change to a PPR (convenience method).""" + return self.log_change( + db=db, + entity_type=EntityType.PPR, + entity_id=ppr_id, entry=entry, user=user, ip=ip ) - return self.create(db, journal_in) + + def get_ppr_journal( + self, + db: Session, + ppr_id: int, + limit: int = 100 + ) -> List[JournalEntry]: + """Get all journal entries for a PPR (convenience method).""" + return self.get_entity_journal( + db=db, + entity_type=EntityType.PPR, + entity_id=ppr_id, + limit=limit + ) journal = CRUDJournal() \ No newline at end of file diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py new file mode 100644 index 0000000..621923c --- /dev/null +++ b/backend/app/crud/crud_local_flight.py @@ -0,0 +1,185 @@ +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 +from app.models.journal import EntityType +from app.models.circuit import Circuit +from app.crud.crud_journal import journal + + +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.created_dt) >= date_from) + + if date_to: + query = query.filter(func.date(LocalFlight.created_dt) <= date_to) + + return query.order_by(desc(LocalFlight.created_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.created_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.created_dt) == today, + or_( + LocalFlight.status == LocalFlightStatus.BOOKED_OUT, + LocalFlight.status == LocalFlightStatus.DEPARTED + ) + ) + ).order_by(LocalFlight.created_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.created_dt) == today, + or_( + LocalFlight.status == LocalFlightStatus.BOOKED_OUT, + LocalFlight.status == LocalFlightStatus.LANDED + ) + ) + ).order_by(LocalFlight.created_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, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight: + from datetime import datetime as dt + + update_data = obj_in.dict(exclude_unset=True) + changes = [] + + for field, value in update_data.items(): + old_value = getattr(db_obj, field) + + # Normalize datetime values for comparison (ignore timezone differences) + if isinstance(old_value, dt) and isinstance(value, dt): + # Compare only the date and time, ignoring timezone + old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value + new_normalized = value.replace(tzinfo=None) if value.tzinfo else value + if old_normalized == new_normalized: + continue # Skip if datetimes are the same + + if old_value != value: + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) + + if changes: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log changes in journal + for change in changes: + journal.log_change( + db, + EntityType.LOCAL_FLIGHT, + db_obj.id, + change, + user, + user_ip + ) + + return db_obj + + def update_status( + self, + db: Session, + flight_id: int, + status: LocalFlightStatus, + timestamp: Optional[datetime] = None, + user: str = "system", + user_ip: Optional[str] = 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) + + old_status = db_obj.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.departed_dt = current_time + elif status == LocalFlightStatus.LANDED: + db_obj.landed_dt = current_time + # Count circuits from the circuits table and populate the circuits column + circuit_count = db.query(func.count(Circuit.id)).filter( + Circuit.local_flight_id == flight_id + ).scalar() + db_obj.circuits = circuit_count + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log status change in journal + journal.log_change( + db, + EntityType.LOCAL_FLIGHT, + flight_id, + f"Status changed from {old_status.value} to {status.value}", + user, + user_ip + ) + + 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/crud/crud_overflight.py b/backend/app/crud/crud_overflight.py new file mode 100644 index 0000000..0b6daf9 --- /dev/null +++ b/backend/app/crud/crud_overflight.py @@ -0,0 +1,172 @@ +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.overflight import Overflight, OverflightStatus +from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate +from app.models.journal import EntityType +from app.crud.crud_journal import journal + + +class CRUDOverflight: + def get(self, db: Session, overflight_id: int) -> Optional[Overflight]: + return db.query(Overflight).filter(Overflight.id == overflight_id).first() + + def get_multi( + self, + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[OverflightStatus] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None + ) -> List[Overflight]: + query = db.query(Overflight) + + if status: + query = query.filter(Overflight.status == status) + + if date_from: + query = query.filter(func.date(Overflight.created_dt) >= date_from) + + if date_to: + query = query.filter(func.date(Overflight.created_dt) <= date_to) + + return query.order_by(desc(Overflight.created_dt)).offset(skip).limit(limit).all() + + def get_active_overflights(self, db: Session) -> List[Overflight]: + """Get currently active overflights""" + return db.query(Overflight).filter( + Overflight.status == OverflightStatus.ACTIVE + ).order_by(desc(Overflight.created_dt)).all() + + def get_overflights_today(self, db: Session) -> List[Overflight]: + """Get today's overflights""" + today = date.today() + return db.query(Overflight).filter( + func.date(Overflight.created_dt) == today + ).order_by(Overflight.created_dt).all() + + def create(self, db: Session, obj_in: OverflightCreate, created_by: str) -> Overflight: + db_obj = Overflight( + **obj_in.dict(), + created_by=created_by, + status=OverflightStatus.ACTIVE + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log creation in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + db_obj.id, + f"Overflight created: {obj_in.registration} from {obj_in.departure_airfield} to {obj_in.destination_airfield}", + created_by, + None + ) + + return db_obj + + def update(self, db: Session, db_obj: Overflight, obj_in: OverflightUpdate, user: str = "system", user_ip: Optional[str] = None) -> Overflight: + from datetime import datetime as dt + + update_data = obj_in.dict(exclude_unset=True) + changes = [] + + for field, value in update_data.items(): + old_value = getattr(db_obj, field) + + # Normalize datetime values for comparison (ignore timezone differences) + if isinstance(old_value, dt) and isinstance(value, dt): + old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value + new_normalized = value.replace(tzinfo=None) if value.tzinfo else value + if old_normalized == new_normalized: + continue + + if old_value != value: + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) + + if changes: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log changes in journal + for change in changes: + journal.log_change( + db, + EntityType.OVERFLIGHT, + db_obj.id, + change, + user, + user_ip + ) + + return db_obj + + def update_status( + self, + db: Session, + overflight_id: int, + status: OverflightStatus, + timestamp: Optional[datetime] = None, + user: str = "system", + user_ip: Optional[str] = None + ) -> Optional[Overflight]: + db_obj = self.get(db, overflight_id) + if not db_obj: + return None + + # Ensure status is an OverflightStatus enum + if isinstance(status, str): + status = OverflightStatus(status) + + old_status = db_obj.status + db_obj.status = status + + # Set timestamp if transitioning to INACTIVE (QSY'd) + current_time = timestamp if timestamp is not None else datetime.utcnow() + if status == OverflightStatus.INACTIVE: + db_obj.qsy_dt = current_time + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log status change in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + overflight_id, + f"Status changed from {old_status.value} to {status.value}", + user, + user_ip + ) + + return db_obj + + def cancel(self, db: Session, overflight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Overflight]: + db_obj = self.get(db, overflight_id) + if db_obj: + old_status = db_obj.status + db_obj.status = OverflightStatus.CANCELLED + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Log cancellation in journal + journal.log_change( + db, + EntityType.OVERFLIGHT, + overflight_id, + f"Status changed from {old_status.value} to CANCELLED", + user, + user_ip + ) + return db_obj + + +overflight = CRUDOverflight() diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index 2d16e75..7b14b7e 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc from datetime import date, datetime import secrets from app.models.ppr import PPRRecord, PPRStatus +from app.models.journal import EntityType from app.schemas.ppr import PPRCreate, PPRUpdate from app.crud.crud_journal import journal as crud_journal @@ -89,6 +90,7 @@ class CRUDPPR: # Log creation in journal crud_journal.log_change( db, + EntityType.PPR, db_obj.id, f"PPR created for {db_obj.ac_reg}", created_by, @@ -98,11 +100,22 @@ class CRUDPPR: return db_obj def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord: + from datetime import datetime as dt + update_data = obj_in.dict(exclude_unset=True) changes = [] for field, value in update_data.items(): old_value = getattr(db_obj, field) + + # Normalize datetime values for comparison (ignore timezone differences) + if isinstance(old_value, dt) and isinstance(value, dt): + # Compare only the date and time, ignoring timezone + old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value + new_normalized = value.replace(tzinfo=None) if value.tzinfo else value + if old_normalized == new_normalized: + continue # Skip if datetimes are the same + if old_value != value: changes.append(f"{field} changed from '{old_value}' to '{value}'") setattr(db_obj, field, value) @@ -114,7 +127,7 @@ class CRUDPPR: # Log changes in journal for change in changes: - crud_journal.log_change(db, db_obj.id, change, user, user_ip) + crud_journal.log_ppr_change(db, db_obj.id, change, user, user_ip) return db_obj @@ -146,7 +159,7 @@ class CRUDPPR: db.refresh(db_obj) # Log status change in journal - crud_journal.log_change( + crud_journal.log_ppr_change( db, db_obj.id, f"Status changed from {old_status.value} to {status.value}", diff --git a/backend/app/main.py b/backend/app/main.py index 17f9971..288d53d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,13 @@ 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, Airport, Aircraft +from app.models.journal import JournalEntry +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) logger = logging.getLogger(__name__) diff --git a/backend/app/models/arrival.py b/backend/app/models/arrival.py new file mode 100644 index 0000000..e913e7c --- /dev/null +++ b/backend/app/models/arrival.py @@ -0,0 +1,30 @@ +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) + created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) + eta = Column(DateTime, nullable=True, 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/circuit.py b/backend/app/models/circuit.py new file mode 100644 index 0000000..c67fa20 --- /dev/null +++ b/backend/app/models/circuit.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, DateTime, BigInteger, ForeignKey +from sqlalchemy.sql import func +from app.db.session import Base + + +class Circuit(Base): + __tablename__ = "circuits" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True) + circuit_timestamp = Column(DateTime, nullable=False, index=True) + created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) diff --git a/backend/app/models/departure.py b/backend/app/models/departure.py new file mode 100644 index 0000000..8cb576b --- /dev/null +++ b/backend/app/models/departure.py @@ -0,0 +1,30 @@ +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(64), nullable=False, index=True) + status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True) + notes = Column(Text, nullable=True) + created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True) + 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) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py new file mode 100644 index 0000000..6a74fae --- /dev/null +++ b/backend/app/models/journal.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, BigInteger, String, Text, DateTime, Index, func +from datetime import datetime +from enum import Enum as PyEnum +from app.db.session import Base + + +class EntityType(str, PyEnum): + """Entity types that can have journal entries""" + PPR = "PPR" + LOCAL_FLIGHT = "LOCAL_FLIGHT" + ARRIVAL = "ARRIVAL" + DEPARTURE = "DEPARTURE" + OVERFLIGHT = "OVERFLIGHT" + + +class JournalEntry(Base): + """ + Generic journal table for tracking changes across all entity types. + Replaces the PPR-specific journal table. + """ + __tablename__ = "journal" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE + entity_id = Column(BigInteger, nullable=False, index=True) # ID of the entity + entry = Column(Text, nullable=False) + user = Column(String(50), nullable=False, index=True) + ip = Column(String(45), nullable=True) # Made optional for new entries + entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) + + # Composite index for efficient queries + __table_args__ = ( + Index('idx_entity_lookup', 'entity_type', 'entity_id'), + ) diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py new file mode 100644 index 0000000..0b716e6 --- /dev/null +++ b/backend/app/models/local_flight.py @@ -0,0 +1,38 @@ +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) + duration = Column(Integer, nullable=True) # Duration in minutes + circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed + notes = Column(Text, nullable=True) + created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) + etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure + departed_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/models/overflight.py b/backend/app/models/overflight.py new file mode 100644 index 0000000..2562684 --- /dev/null +++ b/backend/app/models/overflight.py @@ -0,0 +1,28 @@ +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 OverflightStatus(str, Enum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + CANCELLED = "CANCELLED" + + +class Overflight(Base): + __tablename__ = "overflights" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + registration = Column(String(16), nullable=False, index=True) + pob = Column(Integer, nullable=True) # Persons on board + type = Column(String(32), nullable=True) # Aircraft type + departure_airfield = Column(String(64), nullable=True, index=True) # Airfield they departed from + destination_airfield = Column(String(64), nullable=True, index=True) # Where they're heading + status = Column(SQLEnum(OverflightStatus), nullable=False, default=OverflightStatus.ACTIVE, index=True) + call_dt = Column(DateTime, nullable=False, index=True) # Time of initial call + qsy_dt = Column(DateTime, nullable=True) # Time of frequency change (QSY) + notes = Column(Text, nullable=True) + created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=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/models/ppr.py b/backend/app/models/ppr.py index b23b9a8..9ccc08c 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -60,17 +60,6 @@ class User(Base): updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) -class Journal(Base): - __tablename__ = "journal" - - id = Column(BigInteger, primary_key=True, autoincrement=True) - ppr_id = Column(BigInteger, nullable=False, index=True) # Changed to BigInteger to match submitted.id - entry = Column(Text, nullable=False) - user = Column(String(50), nullable=False, index=True) - ip = Column(String(45), nullable=False) - entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) - - class Airport(Base): __tablename__ = "airports" diff --git a/backend/app/schemas/arrival.py b/backend/app/schemas/arrival.py new file mode 100644 index 0000000..fafbf8e --- /dev/null +++ b/backend/app/schemas/arrival.py @@ -0,0 +1,68 @@ +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): + eta: Optional[datetime] = None + + +class ArrivalUpdate(BaseModel): + registration: Optional[str] = None + 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 + created_dt: datetime + eta: Optional[datetime] = None + landed_dt: Optional[datetime] = None + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/circuit.py b/backend/app/schemas/circuit.py new file mode 100644 index 0000000..775059a --- /dev/null +++ b/backend/app/schemas/circuit.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + + +class CircuitBase(BaseModel): + local_flight_id: int + circuit_timestamp: datetime + + +class CircuitCreate(CircuitBase): + pass + + +class CircuitUpdate(BaseModel): + circuit_timestamp: Optional[datetime] = None + + +class Circuit(CircuitBase): + id: int + created_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..181f3ee --- /dev/null +++ b/backend/app/schemas/departure.py @@ -0,0 +1,65 @@ +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 + etd: Optional[datetime] = None # Estimated Time of Departure + 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): + registration: Optional[str] = None + type: Optional[str] = None + callsign: Optional[str] = None + pob: Optional[int] = None + out_to: Optional[str] = None + etd: Optional[datetime] = None + notes: Optional[str] = None + + +class DepartureStatusUpdate(BaseModel): + status: DepartureStatus + timestamp: Optional[datetime] = None + + +class Departure(DepartureBase): + id: int + status: DepartureStatus + created_dt: datetime + etd: Optional[datetime] = None + departed_dt: Optional[datetime] = None diff --git a/backend/app/schemas/journal.py b/backend/app/schemas/journal.py new file mode 100644 index 0000000..77e799f --- /dev/null +++ b/backend/app/schemas/journal.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + + +class JournalEntryResponse(BaseModel): + """Read-only schema for journal entries""" + id: int + entity_type: str # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE + entity_id: int + entry: str + user: str + ip: Optional[str] + entry_dt: datetime + + class Config: + from_attributes = True + + +class EntityJournalResponse(BaseModel): + """Response containing all journal entries for an entity""" + entity_type: str + entity_id: int + entries: list[JournalEntryResponse] + total_entries: int + + class Config: + from_attributes = True diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py new file mode 100644 index 0000000..dfbc408 --- /dev/null +++ b/backend/app/schemas/local_flight.py @@ -0,0 +1,90 @@ +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: Optional[str] = None # Aircraft type - optional, can be looked up later + 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 + + @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', pre=True, always=False) + def validate_type(cls, v): + 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): + 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 + duration: Optional[int] = None + status: Optional[LocalFlightStatus] = None + etd: Optional[datetime] = None + departed_dt: Optional[datetime] = None + circuits: Optional[int] = None + notes: Optional[str] = None + + +class LocalFlightStatusUpdate(BaseModel): + status: LocalFlightStatus + timestamp: Optional[datetime] = None + + +class LocalFlightInDBBase(LocalFlightBase): + id: int + status: LocalFlightStatus + created_dt: datetime + etd: Optional[datetime] = None + departed_dt: Optional[datetime] = None + landed_dt: Optional[datetime] = None + circuits: Optional[int] = None + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True + + +class LocalFlight(LocalFlightInDBBase): + pass \ No newline at end of file diff --git a/backend/app/schemas/overflight.py b/backend/app/schemas/overflight.py new file mode 100644 index 0000000..0248473 --- /dev/null +++ b/backend/app/schemas/overflight.py @@ -0,0 +1,107 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from enum import Enum + + +class OverflightStatus(str, Enum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + CANCELLED = "CANCELLED" + + +class OverflightBase(BaseModel): + registration: str # Using registration as callsign + pob: Optional[int] = None + type: Optional[str] = None # Aircraft type + departure_airfield: Optional[str] = None + destination_airfield: Optional[str] = None + call_dt: datetime # Time of initial call + qsy_dt: Optional[datetime] = None # Time of frequency change + 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 v and len(v.strip()) > 0: + return v.strip() + return v + + @validator('departure_airfield') + def validate_departure_airfield(cls, v): + if v and len(v.strip()) > 0: + return v.strip().upper() + return v + + @validator('destination_airfield') + def validate_destination_airfield(cls, v): + if v and len(v.strip()) > 0: + return v.strip().upper() + return v + + @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 OverflightCreate(OverflightBase): + pass + + +class OverflightUpdate(BaseModel): + callsign: Optional[str] = None + pob: Optional[int] = None + type: Optional[str] = None + departure_airfield: Optional[str] = None + destination_airfield: Optional[str] = None + call_dt: Optional[datetime] = None + qsy_dt: Optional[datetime] = None + status: Optional[OverflightStatus] = None + notes: Optional[str] = None + + @validator('type') + def validate_type(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip() if v else v + + @validator('departure_airfield') + def validate_departure_airfield(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip().upper() if v else v + + @validator('destination_airfield') + def validate_destination_airfield(cls, v): + if v is not None and len(v.strip()) == 0: + return None + return v.strip().upper() if v else v + + @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 OverflightStatusUpdate(BaseModel): + status: OverflightStatus + qsy_dt: Optional[datetime] = None + + +class Overflight(OverflightBase): + id: int + status: OverflightStatus + created_dt: datetime + created_by: Optional[str] = None + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 3c86422..0151478 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -125,17 +125,23 @@ elif [ $DB_STATE -eq 0 ]; then echo "Checking for pending migrations..." cd /app - # Get current and head revisions - CURRENT=$(alembic current 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || echo "none") - HEAD=$(alembic heads 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || echo "none") + # Get current and head revisions (handle both hash and named revisions) + CURRENT=$(alembic current 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none") + HEAD=$(alembic heads 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none") + + echo " Current: $CURRENT" + echo " Target: $HEAD" if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then echo "✓ Pending migrations detected" - echo " Current: $CURRENT" - echo " Target: $HEAD" echo "Applying migrations..." alembic upgrade head - echo "✓ Migrations applied" + if [ $? -eq 0 ]; then + echo "✓ Migrations applied successfully" + else + echo "✗ Migration failed" + exit 1 + fi else echo "✓ Database is up to date" fi diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9d45d8d..b74c3f6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,6 +26,8 @@ services: MAIL_FROM_NAME: ${MAIL_FROM_NAME} BASE_URL: ${BASE_URL} REDIS_URL: ${REDIS_URL} + TAG: ${TAG} + TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} ENVIRONMENT: production WORKERS: "4" ports: diff --git a/docker-compose.yml b/docker-compose.yml index a48f6c1..73cb302 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,9 @@ services: MAIL_FROM_NAME: ${MAIL_FROM_NAME} BASE_URL: ${BASE_URL} REDIS_URL: ${REDIS_URL} + TOWER_NAME: ${TOWER_NAME} + TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} + ENVIRONMENT: ${ENVIRONMENT} ports: - "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system depends_on: diff --git a/nginx.conf b/nginx.conf index 95eec5f..cac9099 100644 --- a/nginx.conf +++ b/nginx.conf @@ -37,9 +37,14 @@ http { try_files $uri =404; } + # Serve HTML files without .html extension (e.g., /admin -> admin.html) + location ~ ^/([a-zA-Z0-9_-]+)$ { + try_files /$1.html =404; + } + # Serve static files location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/ =404; # Apply X-Frame-Options to other files add_header X-Frame-Options "SAMEORIGIN" always; } @@ -63,6 +68,14 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeout settings (prevent connection drops) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 60s; + + # Additional WebSocket connection settings + proxy_buffering off; } # Security headers diff --git a/web/admin.css b/web/admin.css new file mode 100644 index 0000000..705832f --- /dev/null +++ b/web/admin.css @@ -0,0 +1,762 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f5f5; + color: #333; + padding-bottom: 40px; /* Make room for footer */ +} + +.top-bar { + background: linear-gradient(135deg, #2c3e50, #3498db); + color: white; + padding: 0.5rem 2rem; + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + position: relative; + z-index: 100; +} + +.title { + order: 2; + flex: 1; + text-align: center; +} + +.title h1 { + margin: 0; + font-size: 1.5rem; +} + +.menu-buttons { + order: 1; + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.top-bar .user-info { + order: 3; + font-size: 0.9rem; + opacity: 0.9; + display: flex; + align-items: center; + gap: 0.3rem; +} +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-toggle { + white-space: nowrap; +} + +.dropdown-menu { + display: none; + position: absolute; + background-color: white; + min-width: 200px; + box-shadow: 0 8px 16px rgba(0,0,0,0.2); + border-radius: 5px; + z-index: 1000; + top: 100%; + left: 0; + margin-top: 0.5rem; + padding: 0; +} + +.dropdown-menu a { + color: #333; + padding: 0.75rem 1.5rem; + text-decoration: none; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.shortcut { + font-size: 0.8rem; + color: #999; + margin-left: 1rem; +} + +.dropdown-menu a:hover { + background-color: #f5f5f5; +} + +.dropdown-menu a:first-child { + border-radius: 5px 5px 0 0; +} + +.dropdown-menu a:last-child { + border-radius: 0 0 5px 5px; +} + +.dropdown-menu.active { + display: block; +} + +.top-bar .user-info { + font-size: 0.9rem; + opacity: 0.9; + display: flex; + align-items: center; + gap: 0.3rem; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.btn { + padding: 0.7rem 1.5rem; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s ease; + text-decoration: none; + display: inline-block; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-success { + background-color: #27ae60; + color: white; +} + +.btn-success:hover { + background-color: #229954; +} + +.btn-warning { + background-color: #f39c12; + color: white; +} + +.btn-warning:hover { + background-color: #e67e22; +} + +.btn-info { + background-color: #3498db; + color: white; +} + +.btn-info:hover { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #95a5a6; + color: white; +} + +.btn-secondary:hover { + background-color: #7f8c8d; +} + +.btn-danger { + background-color: #e74c3c; + color: white; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-icon { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; + min-width: auto; +} + +.btn-icon:hover { + transform: scale(1.05); +} + +.filter-group { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.filter-group label { + font-weight: 500; + color: #555; +} + +.filter-group select, .filter-group input { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; +} + +.ppr-table { + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + overflow: hidden; +} + +.table-header { + background: #34495e; + color: white; + padding: 1rem; + font-weight: 500; +} + +.info-icon { + display: inline-block; + cursor: pointer; + font-size: 1.2rem; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.info-icon:hover { + opacity: 1; +} + +.table-header-collapsible { + background: #34495e; + color: white; + padding: 1rem; + font-weight: 500; + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; +} + +.table-header-collapsible:hover { + background: #3d5a6e; +} + +.collapse-icon { + transition: transform 0.3s ease; + font-size: 1.2rem; +} + +.collapse-icon.collapsed { + transform: rotate(-90deg); +} + +.footer-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #34495e; + color: white; + padding: 0.5rem 2rem; + text-align: center; + font-size: 0.85rem; + z-index: 50; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); +} + +.loading { + text-align: center; + padding: 2rem; + color: #666; +} + +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 0.5rem; + text-align: left; + border-bottom: 1px solid #eee; + font-size: 1.4rem; +} + +th { + background-color: #f8f9fa; + font-weight: 600; + color: #495057; + position: sticky; + top: 0; +} + +tbody tr { + cursor: pointer; + transition: background-color 0.2s ease; +} + +tbody tr:hover { + background-color: #f8f9fa; +} + +.status { + display: inline-block; + padding: 0.3rem 0.6rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; +} + +.status.new { background: #e3f2fd; color: #1565c0; } +.status.confirmed { background: #e8f5e8; color: #2e7d32; } +.status.landed { background: #fff3e0; color: #ef6c00; } +.status.departed { background: #fce4ec; color: #c2185b; } +.status.canceled { background: #ffebee; color: #d32f2f; } +.status.deleted { background: #f3e5f5; color: #7b1fa2; } + +.no-data { + text-align: center; + padding: 3rem; + color: #666; +} + +.notes-indicator { + display: inline-block; + background-color: #ffc107; + color: #856404; + font-size: 0.8rem; + padding: 2px 6px; + border-radius: 10px; + margin-left: 5px; + cursor: help; + font-weight: 600; +} + +.notes-tooltip { + position: relative; +} + +.notes-tooltip .tooltip-text { + visibility: hidden; + width: 300px; + background-color: #333; + color: #fff; + text-align: left; + border-radius: 6px; + padding: 8px; + position: fixed; + z-index: 10000; + opacity: 0; + transition: opacity 0.3s; + font-size: 0.9rem; + line-height: 1.4; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + pointer-events: none; +} + +.notes-tooltip .tooltip-text::after { + content: ""; + position: absolute; + top: 50%; + left: -5px; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent #333 transparent transparent; +} + +/* .notes-tooltip:hover .tooltip-text { + visibility: visible; + opacity: 1; +} */ + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); +} + +.modal-content { + background-color: white; + margin: 5% auto; + padding: 0; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); +} + +.modal-header { + background: #34495e; + color: white; + padding: 1rem 1.5rem; + border-radius: 8px 8px 0 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-size: 1.3rem; +} + +.close { + color: white; + font-size: 1.5rem; + font-weight: bold; + cursor: pointer; + border: none; + background: none; +} + +.close:hover { + opacity: 0.7; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + padding: 1rem; + text-align: right; + border-top: 1px solid #ddd; +} + +.modal-footer .btn { + margin-left: 0.5rem; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-weight: 600; + margin-bottom: 0.3rem; + color: #555; +} + +.form-group input, .form-group select, .form-group textarea { + padding: 0.6rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; +} + +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); +} + +#login-form .form-group { + margin-bottom: 1rem; +} + +#login-form .form-group input { + width: 100%; +} + +#login-error { + background-color: #ffebee; + border: 1px solid #ffcdd2; + border-radius: 4px; + padding: 0.8rem; + font-size: 0.9rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: space-between; + padding-top: 1rem; + border-top: 1px solid #eee; +} + +.journal-section { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid #eee; +} + +.journal-entries { + max-height: 200px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 4px; + padding: 1rem; + background-color: #f9f9f9; +} + +.journal-entry { + margin-bottom: 0.8rem; + padding-bottom: 0.8rem; + border-bottom: 1px solid #ddd; +} + +.journal-entry:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.journal-meta { + font-size: 0.8rem; + color: #666; + margin-bottom: 0.3rem; +} + +.journal-text { + font-size: 0.9rem; + color: #333; +} + +.quick-actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +/* Aircraft Lookup Styles */ +#aircraft-lookup-results { + margin-top: 0.5rem; + padding: 0.5rem; + background-color: #f8f9fa; + border-radius: 4px; + font-size: 0.9rem; + min-height: 20px; + border: 1px solid #e9ecef; +} + +.aircraft-match { + padding: 0.3rem; + background-color: #e8f5e8; + border: 1px solid #c3e6c3; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-weight: bold; +} + +.aircraft-no-match { + color: #6c757d; + font-style: italic; +} + +.aircraft-searching { + color: #007bff; +} + +/* Airport Lookup Styles */ +#arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results { + margin-top: 0.5rem; + padding: 0.5rem; + background-color: #f8f9fa; + border-radius: 4px; + font-size: 0.9rem; + min-height: 20px; + border: 1px solid #e9ecef; +} + +.airport-match { + padding: 0.3rem; + background-color: #e8f5e8; + border: 1px solid #c3e6c3; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-weight: bold; +} + +.airport-no-match { + color: #6c757d; + font-style: italic; +} + +.airport-searching { + color: #007bff; +} + +.airport-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; + background-color: white; +} + +.airport-option { + padding: 0.5rem; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + justify-content: space-between; + align-items: center; +} + +.airport-option:hover { + background-color: #f8f9fa; +} + +.airport-option:last-child { + border-bottom: none; +} + +.airport-code { + font-family: 'Courier New', monospace; + font-weight: bold; + color: #495057; +} + +.airport-name { + color: #6c757d; + font-size: 0.85rem; +} + +.airport-location { + color: #868e96; + font-size: 0.8rem; + font-style: italic; +} + +.notification { + position: fixed; + top: 20px; + right: 20px; + background-color: #27ae60; + color: white; + padding: 12px 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + z-index: 10000; + opacity: 0; + transform: translateY(-20px); + transition: all 0.3s ease; + font-weight: 500; + pointer-events: none; +} + +.notification.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.notification.error { + background-color: #e74c3c; +} + +/* Unified Lookup Styles */ +.lookup-no-match { + color: #6c757d; + font-style: italic; +} + +.lookup-searching { + color: #007bff; +} + +.lookup-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; + background-color: white; +} + +.lookup-option { + padding: 0.5rem; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + justify-content: space-between; + align-items: center; +} + +.lookup-option:hover { + background-color: #f8f9fa; +} + +.lookup-option-selected { + background-color: #e3f2fd; + border-left: 3px solid #2196f3; + padding-left: calc(0.5rem - 3px); +} + +.lookup-option-selected:hover { + background-color: #bbdefb; +} + +.lookup-option:last-child { + border-bottom: none; +} + +.lookup-code { + font-family: 'Courier New', monospace; + font-weight: bold; + color: #495057; +} + +.lookup-name { + color: #6c757d; + font-size: 0.85rem; +} + +.lookup-location { + color: #868e96; + font-size: 0.8rem; + font-style: italic; +} diff --git a/web/admin.html b/web/admin.html index 05a7124..95899fc 100644 --- a/web/admin.html +++ b/web/admin.html @@ -4,643 +4,35 @@ PPR Admin Interface - + +
-

✈️ Swansea PPR

+

✈️ Swansea Tower

Logged in as: Loading... | @@ -653,7 +45,10 @@
- 🛬 Today's Pending Arrivals - 0 +
+ 🛬 Today's Pending Arrivals - 0 + ℹ️ +
@@ -666,6 +61,7 @@ Registration + Type From ETA @@ -687,7 +83,10 @@
- 🛫 Today's Pending Departures - 0 +
+ 🛫 Today's Pending Departures - 0 + ℹ️ +
@@ -700,6 +99,7 @@ Registration + Type To ETD @@ -719,13 +119,56 @@

No aircraft currently landed and ready to depart.

+ + +
+
+
+ 🔄 Active Overflights - 0 + ℹ️ +
+
+ +
+
+ Loading overflights... +
+ + + + +

- ✈️ Departed Today - 0 +
+ ✈️ Departed Today - 0 + ℹ️ +