Compare commits
27 Commits
6209c7acce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 211db514dd | |||
| 24971ac5fc | |||
|
|
a1a5f90f00 | ||
| 97995fa58e | |||
| bcd582aee5 | |||
| dc6b551325 | |||
| ac29b6e929 | |||
| 0149f45893 | |||
| 63564b54dd | |||
| 3ab9a6e04c | |||
| b46a88d471 | |||
| 658d4c4ff8 | |||
| a43ab34a8f | |||
| dee5d38b58 | |||
| ee311cc120 | |||
|
|
e63fdc74ec | ||
| a8c0a37b7e | |||
| c92f838489 | |||
|
|
8513a7bb0f | ||
| d183678282 | |||
| a2682314c9 | |||
| f3eb83665f | |||
| f572fb75f5 | |||
| f65c54109e | |||
| d53ddff4be | |||
| 2d4f1467de | |||
| 65eb3272f2 |
@@ -24,6 +24,11 @@ MAIL_FROM_NAME=your_mail_from_name_here
|
|||||||
# Application settings
|
# Application settings
|
||||||
BASE_URL=your_base_url_here
|
BASE_URL=your_base_url_here
|
||||||
|
|
||||||
|
# UI Configuration
|
||||||
|
TAG=
|
||||||
|
TOP_BAR_BASE_COLOR=#2c3e50
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
# Redis (optional)
|
# Redis (optional)
|
||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
# Import all models to ensure they are registered with 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
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Create Date: 2025-12-12 12:00:00.000000
|
|||||||
|
|
||||||
This migration adds a new table for tracking local flights (circuits, local, departure)
|
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,
|
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.
|
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
|
from alembic import op
|
||||||
@@ -22,8 +23,53 @@ depends_on = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""
|
"""
|
||||||
Create local_flights, departures, and arrivals tables.
|
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',
|
op.create_table('local_flights',
|
||||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
sa.Column('registration', sa.String(length=16), nullable=False),
|
sa.Column('registration', sa.String(length=16), nullable=False),
|
||||||
@@ -32,6 +78,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('pob', sa.Integer(), nullable=False),
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), 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('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('notes', sa.Text(), nullable=True),
|
||||||
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
sa.Column('etd', sa.DateTime(), nullable=True),
|
sa.Column('etd', sa.DateTime(), nullable=True),
|
||||||
@@ -109,11 +157,60 @@ def upgrade() -> None:
|
|||||||
op.create_index('idx_arr_eta', 'arrivals', ['eta'])
|
op.create_index('idx_arr_eta', 'arrivals', ['eta'])
|
||||||
op.create_index('idx_arr_created_by', 'arrivals', ['created_by'])
|
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:
|
def downgrade() -> None:
|
||||||
"""
|
"""
|
||||||
Drop the local_flights, departures, and arrivals tables.
|
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('arrivals')
|
||||||
op.drop_table('departures')
|
op.drop_table('departures')
|
||||||
op.drop_table('local_flights')
|
op.drop_table('local_flights')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals
|
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ 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(local_flights.router, prefix="/local-flights", tags=["local_flights"])
|
||||||
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
|
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
|
||||||
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
|
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(public.router, prefix="/public", tags=["public"])
|
||||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||||
@@ -87,7 +87,16 @@ async def update_arrival(
|
|||||||
detail="Arrival record not found"
|
detail="Arrival record not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
arrival = crud_arrival.update(db, db_obj=db_arrival, obj_in=arrival_in)
|
# 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
|
# Send real-time update
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
@@ -112,11 +121,14 @@ async def update_arrival_status(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Update arrival status"""
|
"""Update arrival status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
arrival = crud_arrival.update_status(
|
arrival = crud_arrival.update_status(
|
||||||
db,
|
db,
|
||||||
arrival_id=arrival_id,
|
arrival_id=arrival_id,
|
||||||
status=status_update.status,
|
status=status_update.status,
|
||||||
timestamp=status_update.timestamp
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
)
|
)
|
||||||
if not arrival:
|
if not arrival:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
108
backend/app/api/endpoints/circuits.py
Normal file
108
backend/app/api/endpoints/circuits.py
Normal file
@@ -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"}
|
||||||
@@ -87,7 +87,16 @@ async def update_departure(
|
|||||||
detail="Departure record not found"
|
detail="Departure record not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
departure = crud_departure.update(db, db_obj=db_departure, obj_in=departure_in)
|
# 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
|
# Send real-time update
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
@@ -112,11 +121,14 @@ async def update_departure_status(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Update departure status"""
|
"""Update departure status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
departure = crud_departure.update_status(
|
departure = crud_departure.update_status(
|
||||||
db,
|
db,
|
||||||
departure_id=departure_id,
|
departure_id=departure_id,
|
||||||
status=status_update.status,
|
status=status_update.status,
|
||||||
timestamp=status_update.timestamp
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
)
|
)
|
||||||
if not departure:
|
if not departure:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
63
backend/app/api/endpoints/journal.py
Normal file
63
backend/app/api/endpoints/journal.py
Normal file
@@ -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
|
||||||
@@ -88,7 +88,16 @@ async def update_local_flight(
|
|||||||
detail="Local flight record not found"
|
detail="Local flight record not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
flight = crud_local_flight.update(db, db_obj=db_flight, obj_in=flight_in)
|
# 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
|
# Send real-time update
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
@@ -113,11 +122,14 @@ async def update_local_flight_status(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Update local flight status (LANDED, CANCELLED, etc.)"""
|
"""Update local flight status (LANDED, CANCELLED, etc.)"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
flight = crud_local_flight.update_status(
|
flight = crud_local_flight.update_status(
|
||||||
db,
|
db,
|
||||||
flight_id=flight_id,
|
flight_id=flight_id,
|
||||||
status=status_update.status,
|
status=status_update.status,
|
||||||
timestamp=status_update.timestamp
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
)
|
)
|
||||||
if not flight:
|
if not flight:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
206
backend/app/api/endpoints/overflights.py
Normal file
206
backend/app/api/endpoints/overflights.py
Normal file
@@ -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
|
||||||
@@ -11,10 +11,35 @@ from app.models.local_flight import LocalFlightStatus
|
|||||||
from app.models.departure import DepartureStatus
|
from app.models.departure import DepartureStatus
|
||||||
from app.models.arrival import ArrivalStatus
|
from app.models.arrival import ArrivalStatus
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
import re
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
@router.get("/arrivals")
|
||||||
async def get_public_arrivals(db: Session = Depends(get_db)):
|
async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||||
"""Get today's arrivals for public display (PPR and local flights)"""
|
"""Get today's arrivals for public display (PPR and local flights)"""
|
||||||
@@ -51,12 +76,18 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
|||||||
# Only include flights booked out today
|
# Only include flights booked out today
|
||||||
if not (today_start <= flight.created_dt < today_end):
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
continue
|
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({
|
arrivals_list.append({
|
||||||
'ac_call': flight.callsign or flight.registration,
|
'ac_call': flight.callsign or flight.registration,
|
||||||
'ac_reg': flight.registration,
|
'ac_reg': flight.registration,
|
||||||
'ac_type': flight.type,
|
'ac_type': flight.type,
|
||||||
'in_from': None,
|
'in_from': None,
|
||||||
'eta': flight.departed_dt,
|
'eta': eta,
|
||||||
'landed_dt': None,
|
'landed_dt': None,
|
||||||
'status': 'DEPARTED',
|
'status': 'DEPARTED',
|
||||||
'isLocalFlight': True,
|
'isLocalFlight': True,
|
||||||
@@ -69,10 +100,14 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
|||||||
# Only include BOOKED_IN and LANDED arrivals
|
# Only include BOOKED_IN and LANDED arrivals
|
||||||
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
|
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
|
||||||
continue
|
continue
|
||||||
# For BOOKED_IN, only include those from today; for LANDED, include all
|
# For BOOKED_IN, only include those created today
|
||||||
if arrival.status == ArrivalStatus.BOOKED_IN:
|
if arrival.status == ArrivalStatus.BOOKED_IN:
|
||||||
if not (today_start <= arrival.created_dt < today_end):
|
if not (today_start <= arrival.created_dt < today_end):
|
||||||
continue
|
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({
|
arrivals_list.append({
|
||||||
'registration': arrival.registration,
|
'registration': arrival.registration,
|
||||||
@@ -145,8 +180,16 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
|||||||
limit=1000
|
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
|
# Convert departures to match the format for display
|
||||||
for dep in departures_to_airports:
|
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({
|
departures_list.append({
|
||||||
'ac_call': dep.callsign or dep.registration,
|
'ac_call': dep.callsign or dep.registration,
|
||||||
'ac_reg': dep.registration,
|
'ac_reg': dep.registration,
|
||||||
@@ -159,4 +202,41 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
|||||||
'isDeparture': True
|
'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
|
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
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
|
|||||||
project_name: str = "Airfield PPR API"
|
project_name: str = "Airfield PPR API"
|
||||||
base_url: str
|
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 settings (for future use)
|
||||||
redis_url: Optional[str] = None
|
redis_url: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from sqlalchemy import and_, or_, func, desc
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from app.models.arrival import Arrival, ArrivalStatus
|
from app.models.arrival import Arrival, ArrivalStatus
|
||||||
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
|
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
class CRUDArrival:
|
class CRUDArrival:
|
||||||
@@ -56,16 +58,43 @@ class CRUDArrival:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate) -> Arrival:
|
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)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if value is not None:
|
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)
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def update_status(
|
def update_status(
|
||||||
@@ -73,12 +102,15 @@ class CRUDArrival:
|
|||||||
db: Session,
|
db: Session,
|
||||||
arrival_id: int,
|
arrival_id: int,
|
||||||
status: ArrivalStatus,
|
status: ArrivalStatus,
|
||||||
timestamp: Optional[datetime] = None
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
) -> Optional[Arrival]:
|
) -> Optional[Arrival]:
|
||||||
db_obj = self.get(db, arrival_id)
|
db_obj = self.get(db, arrival_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = status
|
db_obj.status = status
|
||||||
|
|
||||||
if status == ArrivalStatus.LANDED and timestamp:
|
if status == ArrivalStatus.LANDED and timestamp:
|
||||||
@@ -87,6 +119,17 @@ class CRUDArrival:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||||
|
|||||||
55
backend/app/crud/crud_circuit.py
Normal file
55
backend/app/crud/crud_circuit.py
Normal file
@@ -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()
|
||||||
@@ -4,6 +4,8 @@ from sqlalchemy import and_, or_, func, desc
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from app.models.departure import Departure, DepartureStatus
|
from app.models.departure import Departure, DepartureStatus
|
||||||
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
|
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
class CRUDDeparture:
|
class CRUDDeparture:
|
||||||
@@ -56,16 +58,43 @@ class CRUDDeparture:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate) -> Departure:
|
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)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if value is not None:
|
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)
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def update_status(
|
def update_status(
|
||||||
@@ -73,12 +102,15 @@ class CRUDDeparture:
|
|||||||
db: Session,
|
db: Session,
|
||||||
departure_id: int,
|
departure_id: int,
|
||||||
status: DepartureStatus,
|
status: DepartureStatus,
|
||||||
timestamp: Optional[datetime] = None
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
) -> Optional[Departure]:
|
) -> Optional[Departure]:
|
||||||
db_obj = self.get(db, departure_id)
|
db_obj = self.get(db, departure_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = status
|
db_obj.status = status
|
||||||
|
|
||||||
if status == DepartureStatus.DEPARTED and timestamp:
|
if status == DepartureStatus.DEPARTED and timestamp:
|
||||||
@@ -87,6 +119,17 @@ class CRUDDeparture:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||||
|
|||||||
@@ -1,35 +1,95 @@
|
|||||||
from typing import List
|
from typing import List, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.ppr import Journal
|
from app.models.journal import JournalEntry, EntityType
|
||||||
from app.schemas.ppr import JournalCreate
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class CRUDJournal:
|
class CRUDJournal:
|
||||||
def create(self, db: Session, obj_in: JournalCreate) -> Journal:
|
"""CRUD operations for the generic journal table.
|
||||||
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]:
|
This journal is immutable - entries can only be created (by backend) and queried.
|
||||||
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
|
There are no API endpoints for creating journal entries; the backend logs changes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
def log_change(
|
def log_change(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: int,
|
||||||
|
entry: str,
|
||||||
|
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,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
ppr_id: int,
|
ppr_id: int,
|
||||||
entry: str,
|
entry: str,
|
||||||
user: str,
|
user: str,
|
||||||
ip: str
|
ip: Optional[str] = None
|
||||||
) -> Journal:
|
) -> JournalEntry:
|
||||||
journal_in = JournalCreate(
|
"""Log a change to a PPR (convenience method)."""
|
||||||
ppr_id=ppr_id,
|
return self.log_change(
|
||||||
|
db=db,
|
||||||
|
entity_type=EntityType.PPR,
|
||||||
|
entity_id=ppr_id,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
user=user,
|
user=user,
|
||||||
ip=ip
|
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()
|
journal = CRUDJournal()
|
||||||
@@ -4,6 +4,9 @@ from sqlalchemy import and_, or_, func, desc
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||||
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
|
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:
|
class CRUDLocalFlight:
|
||||||
@@ -82,16 +85,43 @@ class CRUDLocalFlight:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate) -> LocalFlight:
|
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)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if value is not None:
|
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)
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def update_status(
|
def update_status(
|
||||||
@@ -99,7 +129,9 @@ class CRUDLocalFlight:
|
|||||||
db: Session,
|
db: Session,
|
||||||
flight_id: int,
|
flight_id: int,
|
||||||
status: LocalFlightStatus,
|
status: LocalFlightStatus,
|
||||||
timestamp: Optional[datetime] = None
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
) -> Optional[LocalFlight]:
|
) -> Optional[LocalFlight]:
|
||||||
db_obj = self.get(db, flight_id)
|
db_obj = self.get(db, flight_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
@@ -109,6 +141,7 @@ class CRUDLocalFlight:
|
|||||||
if isinstance(status, str):
|
if isinstance(status, str):
|
||||||
status = LocalFlightStatus(status)
|
status = LocalFlightStatus(status)
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = status
|
db_obj.status = status
|
||||||
|
|
||||||
# Set timestamps based on status
|
# Set timestamps based on status
|
||||||
@@ -117,10 +150,26 @@ class CRUDLocalFlight:
|
|||||||
db_obj.departed_dt = current_time
|
db_obj.departed_dt = current_time
|
||||||
elif status == LocalFlightStatus.LANDED:
|
elif status == LocalFlightStatus.LANDED:
|
||||||
db_obj.landed_dt = current_time
|
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.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
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
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
||||||
|
|||||||
172
backend/app/crud/crud_overflight.py
Normal file
172
backend/app/crud/crud_overflight.py
Normal file
@@ -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()
|
||||||
@@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.ppr import PPRRecord, PPRStatus
|
from app.models.ppr import PPRRecord, PPRStatus
|
||||||
|
from app.models.journal import EntityType
|
||||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||||
from app.crud.crud_journal import journal as crud_journal
|
from app.crud.crud_journal import journal as crud_journal
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ class CRUDPPR:
|
|||||||
# Log creation in journal
|
# Log creation in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_change(
|
||||||
db,
|
db,
|
||||||
|
EntityType.PPR,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"PPR created for {db_obj.ac_reg}",
|
f"PPR created for {db_obj.ac_reg}",
|
||||||
created_by,
|
created_by,
|
||||||
@@ -98,11 +100,22 @@ class CRUDPPR:
|
|||||||
return db_obj
|
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:
|
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)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
changes = []
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
old_value = getattr(db_obj, field)
|
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:
|
if old_value != value:
|
||||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
setattr(db_obj, field, value)
|
setattr(db_obj, field, value)
|
||||||
@@ -114,7 +127,7 @@ class CRUDPPR:
|
|||||||
|
|
||||||
# Log changes in journal
|
# Log changes in journal
|
||||||
for change in changes:
|
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
|
return db_obj
|
||||||
|
|
||||||
@@ -146,7 +159,7 @@ class CRUDPPR:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
# Log status change in journal
|
# Log status change in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_ppr_change(
|
||||||
db,
|
db,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"Status changed from {old_status.value} to {status.value}",
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from app.core.config import settings
|
|||||||
from app.api.api import api_router
|
from app.api.api import api_router
|
||||||
|
|
||||||
# Import models to ensure they're registered with SQLAlchemy
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
from app.models.ppr import PPRRecord, User, Journal, Airport, Aircraft
|
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.local_flight import LocalFlight
|
||||||
from app.models.departure import Departure
|
from app.models.departure import Departure
|
||||||
from app.models.arrival import Arrival
|
from app.models.arrival import Arrival
|
||||||
|
|||||||
12
backend/app/models/circuit.py
Normal file
12
backend/app/models/circuit.py
Normal file
@@ -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())
|
||||||
34
backend/app/models/journal.py
Normal file
34
backend/app/models/journal.py
Normal file
@@ -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'),
|
||||||
|
)
|
||||||
@@ -27,6 +27,8 @@ class LocalFlight(Base):
|
|||||||
pob = Column(Integer, nullable=False) # Persons on board
|
pob = Column(Integer, nullable=False) # Persons on board
|
||||||
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
||||||
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, 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)
|
notes = Column(Text, nullable=True)
|
||||||
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=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
|
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
|
||||||
|
|||||||
28
backend/app/models/overflight.py
Normal file
28
backend/app/models/overflight.py
Normal file
@@ -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())
|
||||||
@@ -60,17 +60,6 @@ class User(Base):
|
|||||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
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):
|
class Airport(Base):
|
||||||
__tablename__ = "airports"
|
__tablename__ = "airports"
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ArrivalCreate(ArrivalBase):
|
|||||||
|
|
||||||
|
|
||||||
class ArrivalUpdate(BaseModel):
|
class ArrivalUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
callsign: Optional[str] = None
|
callsign: Optional[str] = None
|
||||||
pob: Optional[int] = None
|
pob: Optional[int] = None
|
||||||
|
|||||||
24
backend/app/schemas/circuit.py
Normal file
24
backend/app/schemas/circuit.py
Normal file
@@ -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
|
||||||
@@ -43,6 +43,7 @@ class DepartureCreate(DepartureBase):
|
|||||||
|
|
||||||
|
|
||||||
class DepartureUpdate(BaseModel):
|
class DepartureUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
callsign: Optional[str] = None
|
callsign: Optional[str] = None
|
||||||
pob: Optional[int] = None
|
pob: Optional[int] = None
|
||||||
|
|||||||
28
backend/app/schemas/journal.py
Normal file
28
backend/app/schemas/journal.py
Normal file
@@ -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
|
||||||
@@ -23,6 +23,7 @@ class LocalFlightBase(BaseModel):
|
|||||||
callsign: Optional[str] = None
|
callsign: Optional[str] = None
|
||||||
pob: int
|
pob: int
|
||||||
flight_type: LocalFlightType
|
flight_type: LocalFlightType
|
||||||
|
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||||
etd: Optional[datetime] = None # Estimated Time of Departure
|
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
@@ -57,9 +58,11 @@ class LocalFlightUpdate(BaseModel):
|
|||||||
callsign: Optional[str] = None
|
callsign: Optional[str] = None
|
||||||
pob: Optional[int] = None
|
pob: Optional[int] = None
|
||||||
flight_type: Optional[LocalFlightType] = None
|
flight_type: Optional[LocalFlightType] = None
|
||||||
|
duration: Optional[int] = None
|
||||||
status: Optional[LocalFlightStatus] = None
|
status: Optional[LocalFlightStatus] = None
|
||||||
etd: Optional[datetime] = None
|
etd: Optional[datetime] = None
|
||||||
departed_dt: Optional[datetime] = None
|
departed_dt: Optional[datetime] = None
|
||||||
|
circuits: Optional[int] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ class LocalFlightInDBBase(LocalFlightBase):
|
|||||||
etd: Optional[datetime] = None
|
etd: Optional[datetime] = None
|
||||||
departed_dt: Optional[datetime] = None
|
departed_dt: Optional[datetime] = None
|
||||||
landed_dt: Optional[datetime] = None
|
landed_dt: Optional[datetime] = None
|
||||||
|
circuits: Optional[int] = None
|
||||||
created_by: Optional[str] = None
|
created_by: Optional[str] = None
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
107
backend/app/schemas/overflight.py
Normal file
107
backend/app/schemas/overflight.py
Normal file
@@ -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
|
||||||
@@ -125,17 +125,23 @@ elif [ $DB_STATE -eq 0 ]; then
|
|||||||
echo "Checking for pending migrations..."
|
echo "Checking for pending migrations..."
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# Get current and head revisions
|
# Get current and head revisions (handle both hash and named revisions)
|
||||||
CURRENT=$(alembic current 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || echo "none")
|
CURRENT=$(alembic current 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
|
||||||
HEAD=$(alembic heads 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || 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
|
if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then
|
||||||
echo "✓ Pending migrations detected"
|
echo "✓ Pending migrations detected"
|
||||||
echo " Current: $CURRENT"
|
|
||||||
echo " Target: $HEAD"
|
|
||||||
echo "Applying migrations..."
|
echo "Applying migrations..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
echo "✓ Migrations applied"
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Migrations applied successfully"
|
||||||
|
else
|
||||||
|
echo "✗ Migration failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✓ Database is up to date"
|
echo "✓ Database is up to date"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TAG: ${TAG}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
WORKERS: "4"
|
WORKERS: "4"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ services:
|
|||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TOWER_NAME: ${TOWER_NAME}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT}
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
15
nginx.conf
15
nginx.conf
@@ -37,9 +37,14 @@ http {
|
|||||||
try_files $uri =404;
|
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
|
# Serve static files
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ =404;
|
||||||
# Apply X-Frame-Options to other files
|
# Apply X-Frame-Options to other files
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,14 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# Security headers
|
||||||
|
|||||||
@@ -16,22 +16,95 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.title h1 {
|
.title h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-buttons {
|
.menu-buttons {
|
||||||
|
order: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
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 {
|
.top-bar .user-info {
|
||||||
@@ -96,6 +169,15 @@ body {
|
|||||||
background-color: #2980b9;
|
background-color: #2980b9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background-color: #e74c3c;
|
background-color: #e74c3c;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -315,10 +397,10 @@ tbody tr:hover {
|
|||||||
border-color: transparent #333 transparent transparent;
|
border-color: transparent #333 transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes-tooltip:hover .tooltip-text {
|
/* .notes-tooltip:hover .tooltip-text {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
} */
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal {
|
.modal {
|
||||||
|
|||||||
1598
web/admin.html
1598
web/admin.html
File diff suppressed because it is too large
Load Diff
27
web/assets/bell.svg
Normal file
27
web/assets/bell.svg
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Bell body -->
|
||||||
|
<path d="M 30,40 Q 30,35 35,35 L 65,35 Q 70,35 70,40 Q 70,60 50,70 Q 30,60 30,40" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Bell shine/highlight -->
|
||||||
|
<ellipse cx="45" cy="45" rx="8" ry="6" fill="#FFED4E" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Bell clapper -->
|
||||||
|
<circle cx="50" cy="65" r="4" fill="#8B4513"/>
|
||||||
|
<path d="M 50,65 Q 48,75 47,85" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Top of bell (rope/hanging part) -->
|
||||||
|
<rect x="47" y="25" width="6" height="10" fill="#DAA520" rx="2"/>
|
||||||
|
|
||||||
|
<!-- Loop -->
|
||||||
|
<path d="M 48,25 Q 40,20 50,15 Q 60,20 52,25" stroke="#DAA520" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Decorative berries around bell -->
|
||||||
|
<circle cx="25" cy="50" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="75" cy="50" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="28" cy="60" r="2" fill="#E74C3C"/>
|
||||||
|
<circle cx="72" cy="60" r="2" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Holly leaves -->
|
||||||
|
<path d="M 20,45 L 18,48 L 20,50 L 18,52 L 20,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 80,45 L 82,48 L 80,50 L 82,52 L 80,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
10
web/assets/candycane.svg
Normal file
10
web/assets/candycane.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Candy cane curve -->
|
||||||
|
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#E74C3C" stroke-width="12" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- White stripe -->
|
||||||
|
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#FFFFFF" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="8,8"/>
|
||||||
|
|
||||||
|
<!-- Highlight -->
|
||||||
|
<path d="M 48,15 Q 32,32 32,60 Q 32,88 48,98" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 546 B |
25
web/assets/gift.svg
Normal file
25
web/assets/gift.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Box -->
|
||||||
|
<rect x="15" y="30" width="70" height="70" fill="#E74C3C" stroke="#C0392B" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Box lid/3D effect -->
|
||||||
|
<polygon points="15,30 25,20 85,20 75,30" fill="#C0392B"/>
|
||||||
|
<polygon points="75,30 85,20 85,90 75,100" fill="#A93226"/>
|
||||||
|
|
||||||
|
<!-- Ribbon vertical -->
|
||||||
|
<rect x="42" y="20" width="16" height="85" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Ribbon horizontal -->
|
||||||
|
<rect x="10" y="57" width="80" height="16" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Bow on top -->
|
||||||
|
<ellipse cx="35" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
<ellipse cx="65" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
|
||||||
|
<circle cx="50" cy="20" r="5" fill="#DAA520"/>
|
||||||
|
|
||||||
|
<!-- Pattern on box -->
|
||||||
|
<circle cx="30" cy="50" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="70" cy="60" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="50" cy="75" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
<circle cx="35" cy="80" r="3" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
39
web/assets/reindeer.svg
Normal file
39
web/assets/reindeer.svg
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Antlers -->
|
||||||
|
<path d="M 35,25 Q 25,10 20,5" stroke="#8B4513" stroke-width="3" fill="none"/>
|
||||||
|
<path d="M 65,25 Q 75,10 80,5" stroke="#8B4513" stroke-width="3" fill="none"/>
|
||||||
|
<path d="M 33,22 Q 22,15 15,8" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M 67,22 Q 78,15 85,8" stroke="#8B4513" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="35" r="15" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Ears -->
|
||||||
|
<ellipse cx="38" cy="22" rx="5" ry="8" fill="#8B4513"/>
|
||||||
|
<ellipse cx="62" cy="22" rx="5" ry="8" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle cx="45" cy="32" r="2" fill="#000000"/>
|
||||||
|
<circle cx="55" cy="32" r="2" fill="#000000"/>
|
||||||
|
|
||||||
|
<!-- Nose (red) -->
|
||||||
|
<circle cx="50" cy="40" r="4" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path d="M 48,45 Q 50,47 52,45" stroke="#000000" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Neck -->
|
||||||
|
<rect x="43" y="48" width="14" height="8" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<ellipse cx="50" cy="70" rx="20" ry="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="35" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="45" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="55" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
<rect x="65" y="90" width="6" height="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Tail -->
|
||||||
|
<circle cx="68" cy="65" r="5" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
37
web/assets/santa.svg
Normal file
37
web/assets/santa.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Santa hat -->
|
||||||
|
<polygon points="20,20 80,20 75,35 25,35" fill="#E74C3C"/>
|
||||||
|
<circle cx="50" cy="18" r="8" fill="#E74C3C"/>
|
||||||
|
<circle cx="77" cy="28" r="6" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- Face -->
|
||||||
|
<circle cx="50" cy="50" r="18" fill="#F5DEB3"/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle cx="42" cy="45" r="2.5" fill="#000000"/>
|
||||||
|
<circle cx="58" cy="45" r="2.5" fill="#000000"/>
|
||||||
|
|
||||||
|
<!-- Nose -->
|
||||||
|
<circle cx="50" cy="52" r="2" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Beard -->
|
||||||
|
<path d="M 35,58 Q 35,65 50,68 Q 65,65 65,58" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path d="M 42,60 Q 50,63 58,60" stroke="#000000" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="35" y="68" width="30" height="25" rx="5" fill="#E74C3C"/>
|
||||||
|
|
||||||
|
<!-- Belt -->
|
||||||
|
<rect x="32" y="85" width="36" height="4" fill="#000000"/>
|
||||||
|
<circle cx="68" cy="87" r="2.5" fill="#FFD700"/>
|
||||||
|
|
||||||
|
<!-- Arms -->
|
||||||
|
<rect x="15" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
|
||||||
|
<rect x="65" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
|
||||||
|
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="40" y="93" width="8" height="20" fill="#000000"/>
|
||||||
|
<rect x="52" y="93" width="8" height="20" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
17
web/assets/tree.svg
Normal file
17
web/assets/tree.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Tree trunk -->
|
||||||
|
<rect x="40" y="80" width="20" height="25" fill="#8B4513"/>
|
||||||
|
|
||||||
|
<!-- Tree layers -->
|
||||||
|
<polygon points="50,10 20,50 30,50 10,80 40,80 5,110 50,110 95,110 60,80 90,80 70,50 80,50" fill="#228B22"/>
|
||||||
|
|
||||||
|
<!-- Tree highlights -->
|
||||||
|
<circle cx="50" cy="35" r="4" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="35" cy="55" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="65" cy="60" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="45" cy="80" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
<circle cx="55" cy="90" r="3" fill="#FFD700" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Star on top -->
|
||||||
|
<polygon points="50,5 55,15 65,15 57,20 60,30 50,25 40,30 43,20 35,15 45,15" fill="#FFD700"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 758 B |
480
web/index.html
480
web/index.html
@@ -132,6 +132,226 @@
|
|||||||
grid-template-columns: 1fr; /* Stack columns on smaller screens */
|
grid-template-columns: 1fr; /* Stack columns on smaller screens */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Christmas toggle switch */
|
||||||
|
.christmas-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox {
|
||||||
|
width: 50px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-color: #555;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked {
|
||||||
|
background-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: left 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked::before {
|
||||||
|
left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Santa hat styles */
|
||||||
|
.santa-hat {
|
||||||
|
position: absolute;
|
||||||
|
width: 60px;
|
||||||
|
height: 50px;
|
||||||
|
top: -20px;
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.santa-hat::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||||
|
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.santa-hat::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
bottom: -5px;
|
||||||
|
right: -8px;
|
||||||
|
box-shadow: -15px 5px 0 -5px white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jingle bell styles */
|
||||||
|
.jingle-bell {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 12px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0 2px;
|
||||||
|
animation: jingle 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jingle-bell::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #f1c40f;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jingle-bell::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 6px;
|
||||||
|
background: #d4a500;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jingle {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
25% { transform: rotate(5deg); }
|
||||||
|
75% { transform: rotate(-5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snow animation */
|
||||||
|
.snowflake {
|
||||||
|
position: fixed;
|
||||||
|
top: -10px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 5px rgba(255,255,255,0.8);
|
||||||
|
z-index: 1;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: snowfall linear infinite;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes snowfall {
|
||||||
|
to {
|
||||||
|
transform: translateY(100vh) translateX(100px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.christmas-active .snowflake {
|
||||||
|
animation: snowfall linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Festive header when active */
|
||||||
|
body.christmas-active header {
|
||||||
|
background: linear-gradient(90deg, #27ae60 0%, #e74c3c 50%, #27ae60 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: festive-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes festive-pulse {
|
||||||
|
0%, 100% { background-position: 0% 0%; }
|
||||||
|
50% { background-position: 100% 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jingle bells in header when active */
|
||||||
|
body.christmas-active h1::before {
|
||||||
|
content: '🔔 ';
|
||||||
|
animation: jingle 0.4s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 30px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.christmas-active h1::after {
|
||||||
|
content: ' 🔔';
|
||||||
|
animation: jingle 0.4s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 30px;
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner decorations */
|
||||||
|
.corner-decoration {
|
||||||
|
position: fixed;
|
||||||
|
font-size: 80px;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.9;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-decoration img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom decorations */
|
||||||
|
.bottom-decoration {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-decoration img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-decoration.bottom-left {
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
animation: sway 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-decoration.bottom-right {
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
animation: sway 3s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sway {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(-5deg); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -191,6 +411,118 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Christmas mode toggle functionality
|
||||||
|
function initChristmasMode() {
|
||||||
|
// Check URL parameter first for override
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const christmasParam = urlParams.get('christmas');
|
||||||
|
|
||||||
|
let shouldEnable = false;
|
||||||
|
|
||||||
|
if (christmasParam === 'on') {
|
||||||
|
shouldEnable = true;
|
||||||
|
} else if (christmasParam === 'off') {
|
||||||
|
shouldEnable = false;
|
||||||
|
} else {
|
||||||
|
// Auto-enable for December
|
||||||
|
const now = new Date();
|
||||||
|
shouldEnable = now.getMonth() === 11; // December is month 11 (0-indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldEnable) {
|
||||||
|
enableChristmasMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableChristmasMode() {
|
||||||
|
document.body.classList.add('christmas-active');
|
||||||
|
|
||||||
|
// Create falling snowflakes
|
||||||
|
function createSnowflake() {
|
||||||
|
const snowflake = document.createElement('div');
|
||||||
|
snowflake.classList.add('snowflake');
|
||||||
|
snowflake.textContent = '❄';
|
||||||
|
snowflake.style.left = Math.random() * window.innerWidth + 'px';
|
||||||
|
snowflake.style.animationDuration = (Math.random() * 5 + 8) + 's';
|
||||||
|
snowflake.style.animationDelay = Math.random() * 2 + 's';
|
||||||
|
|
||||||
|
document.body.appendChild(snowflake);
|
||||||
|
|
||||||
|
setTimeout(() => snowflake.remove(), 13000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create snowflakes periodically
|
||||||
|
const snowInterval = setInterval(() => {
|
||||||
|
if (!document.body.classList.contains('christmas-active')) {
|
||||||
|
clearInterval(snowInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createSnowflake();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Add corner decorations
|
||||||
|
const leftCorner = document.createElement('div');
|
||||||
|
leftCorner.classList.add('corner-decoration', 'bottom-left');
|
||||||
|
const treeImg = document.createElement('img');
|
||||||
|
treeImg.src = 'assets/tree.svg';
|
||||||
|
treeImg.alt = 'Christmas Tree';
|
||||||
|
leftCorner.appendChild(treeImg);
|
||||||
|
leftCorner.id = 'corner-left';
|
||||||
|
document.body.appendChild(leftCorner);
|
||||||
|
|
||||||
|
const rightCorner = document.createElement('div');
|
||||||
|
rightCorner.classList.add('corner-decoration', 'bottom-right');
|
||||||
|
const santaImg = document.createElement('img');
|
||||||
|
santaImg.src = 'assets/santa.svg';
|
||||||
|
santaImg.alt = 'Santa';
|
||||||
|
rightCorner.appendChild(santaImg);
|
||||||
|
rightCorner.id = 'corner-right';
|
||||||
|
document.body.appendChild(rightCorner);
|
||||||
|
|
||||||
|
// Add bottom decorations in a row
|
||||||
|
const bottomDecorations = [
|
||||||
|
{ src: 'assets/reindeer.svg', alt: 'Reindeer' },
|
||||||
|
{ src: 'assets/bell.svg', alt: 'Bell' },
|
||||||
|
{ src: 'assets/gift.svg', alt: 'Gift' },
|
||||||
|
{ src: 'assets/candycane.svg', alt: 'Candy Cane' },
|
||||||
|
{ src: 'assets/bell.svg', alt: 'Bell' },
|
||||||
|
{ src: 'assets/gift.svg', alt: 'Gift' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const screenWidth = window.innerWidth;
|
||||||
|
const totalDecorations = bottomDecorations.length;
|
||||||
|
const spacing = screenWidth / (totalDecorations + 1);
|
||||||
|
|
||||||
|
bottomDecorations.forEach((deco, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.classList.add('bottom-decoration');
|
||||||
|
div.style.left = (spacing * (index + 1) - 40) + 'px'; // 40 is half the width
|
||||||
|
div.style.animation = `sway ${3 + index * 0.5}s ease-in-out infinite`;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = deco.src;
|
||||||
|
img.alt = deco.alt;
|
||||||
|
div.appendChild(img);
|
||||||
|
div.id = `bottom-deco-${index}`;
|
||||||
|
|
||||||
|
document.body.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableChristmasMode() {
|
||||||
|
document.body.classList.remove('christmas-active');
|
||||||
|
|
||||||
|
// Remove corner decorations
|
||||||
|
document.getElementById('corner-left')?.remove();
|
||||||
|
document.getElementById('corner-right')?.remove();
|
||||||
|
|
||||||
|
// Remove bottom decorations
|
||||||
|
document.querySelectorAll('[id^="bottom-deco-"]').forEach(deco => deco.remove());
|
||||||
|
|
||||||
|
// Remove snowflakes
|
||||||
|
document.querySelectorAll('.snowflake').forEach(flake => flake.remove());
|
||||||
|
}
|
||||||
|
|
||||||
let wsConnection = null;
|
let wsConnection = null;
|
||||||
|
|
||||||
// ICAO code to airport name cache
|
// ICAO code to airport name cache
|
||||||
@@ -302,10 +634,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows asynchronously to lookup airport names
|
// Build rows with metadata for sorting
|
||||||
const rows = await Promise.all(arrivals.map(async (arrival) => {
|
const rowsWithData = await Promise.all(arrivals.map(async (arrival) => {
|
||||||
const isLocal = arrival.isLocalFlight;
|
const isLocal = arrival.isLocalFlight;
|
||||||
const isBookedIn = arrival.isBookedIn;
|
const isBookedIn = arrival.isBookedIn;
|
||||||
|
const isLanded = arrival.status === 'LANDED' || (arrival.status === 'DEPARTED' && arrival.landed_dt);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let sortKey = '';
|
||||||
|
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
// Local flight
|
// Local flight
|
||||||
@@ -313,65 +649,74 @@
|
|||||||
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
||||||
const fromDisplay = `<i>${getFlightTypeDisplay(arrival.flight_type)}</i>`;
|
const fromDisplay = `<i>${getFlightTypeDisplay(arrival.flight_type)}</i>`;
|
||||||
const time = convertToLocalTime(arrival.eta);
|
const time = convertToLocalTime(arrival.eta);
|
||||||
const timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
const timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${fromDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = `0-${arrival.eta}`; // Live flights, sort by ETA
|
||||||
<td>${aircraftDisplay}</td>
|
|
||||||
<td>${fromDisplay}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
} else if (isBookedIn) {
|
} else if (isBookedIn) {
|
||||||
// Booked-in arrival
|
// Booked-in arrival
|
||||||
const aircraftId = arrival.callsign || arrival.registration || '';
|
const aircraftId = arrival.callsign || arrival.registration || '';
|
||||||
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.type || '')})</span>`;
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.type || '')})</span>`;
|
||||||
const fromDisplay = await getAirportName(arrival.in_from || '');
|
const fromDisplay = await getAirportName(arrival.in_from || '');
|
||||||
|
|
||||||
let timeDisplay;
|
let timeDisplay, sortTime;
|
||||||
if (arrival.status === 'LANDED' && arrival.landed_dt) {
|
if (arrival.status === 'LANDED' && arrival.landed_dt) {
|
||||||
// Show landed time if LANDED
|
// Show landed time if LANDED
|
||||||
const time = convertToLocalTime(arrival.landed_dt);
|
const time = convertToLocalTime(arrival.landed_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
||||||
|
sortTime = arrival.landed_dt;
|
||||||
} else {
|
} else {
|
||||||
// Show ETA if BOOKED_IN
|
// Show ETA if BOOKED_IN
|
||||||
const time = convertToLocalTime(arrival.eta);
|
const time = convertToLocalTime(arrival.eta);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
||||||
|
sortTime = arrival.eta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(fromDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = isLanded ? `1-${sortTime}` : `0-${sortTime}`; // 0=live, 1=completed
|
||||||
<td>${aircraftDisplay}</td>
|
|
||||||
<td>${escapeHtml(fromDisplay)}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
} else {
|
} else {
|
||||||
// PPR
|
// PPR
|
||||||
const aircraftId = arrival.ac_call || arrival.ac_reg || '';
|
const aircraftId = arrival.ac_call || arrival.ac_reg || '';
|
||||||
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
||||||
const fromDisplay = await getAirportName(arrival.in_from || '');
|
const fromDisplay = await getAirportName(arrival.in_from || '');
|
||||||
|
|
||||||
// Show landed time if available, otherwise ETA
|
let timeDisplay, sortTime;
|
||||||
let timeDisplay;
|
|
||||||
if ((arrival.status === 'LANDED' || arrival.status === 'DEPARTED') && arrival.landed_dt) {
|
if ((arrival.status === 'LANDED' || arrival.status === 'DEPARTED') && arrival.landed_dt) {
|
||||||
const time = convertToLocalTime(arrival.landed_dt);
|
const time = convertToLocalTime(arrival.landed_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
||||||
|
sortTime = arrival.landed_dt;
|
||||||
} else {
|
} else {
|
||||||
timeDisplay = convertToLocalTime(arrival.eta);
|
timeDisplay = convertToLocalTime(arrival.eta);
|
||||||
|
sortTime = arrival.eta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(fromDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = isLanded ? `1-${sortTime}` : `0-${sortTime}`;
|
||||||
<td>${aircraftDisplay}</td>
|
|
||||||
<td>${escapeHtml(fromDisplay)}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { html, sortKey, isLanded };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tbody.innerHTML = rows.join('');
|
// Sort: live flights first (0) by ETA ascending, then completed flights (1) by time descending
|
||||||
|
rowsWithData.sort((a, b) => {
|
||||||
|
const aParts = a.sortKey.split('-');
|
||||||
|
const bParts = b.sortKey.split('-');
|
||||||
|
const aType = parseInt(aParts[0]);
|
||||||
|
const bType = parseInt(bParts[0]);
|
||||||
|
|
||||||
|
if (aType !== bType) return aType - bType; // Live before completed
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
if (aType === 0) {
|
||||||
|
// Live flights: ascending (earliest first)
|
||||||
|
return aParts[1].localeCompare(bParts[1]);
|
||||||
|
} else {
|
||||||
|
// Completed flights: descending (most recent first)
|
||||||
|
return bParts[1].localeCompare(aParts[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = rowsWithData.map(r => r.html).join('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading arrivals:', error);
|
console.error('Error loading arrivals:', error);
|
||||||
@@ -397,10 +742,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows asynchronously to lookup airport names
|
// Build rows with metadata for sorting
|
||||||
const rows = await Promise.all(departures.map(async (departure) => {
|
const rowsWithData = await Promise.all(departures.map(async (departure) => {
|
||||||
const isLocal = departure.isLocalFlight;
|
const isLocal = departure.isLocalFlight;
|
||||||
const isDeparture = departure.isDeparture;
|
const isDeparture = departure.isDeparture;
|
||||||
|
const isDeparted = departure.status === 'DEPARTED';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let sortKey = '';
|
||||||
|
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
// Local flight
|
// Local flight
|
||||||
@@ -410,54 +759,70 @@
|
|||||||
const time = convertToLocalTime(departure.etd);
|
const time = convertToLocalTime(departure.etd);
|
||||||
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${toDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = `0-${departure.etd}`; // Live flights, sort by ETD
|
||||||
<td>${aircraftDisplay}</td>
|
|
||||||
<td>${toDisplay}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
} else if (isDeparture) {
|
} else if (isDeparture) {
|
||||||
// Departure to other airport
|
// Departure to other airport
|
||||||
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
||||||
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
||||||
const toDisplay = await getAirportName(departure.out_to || '');
|
const toDisplay = await getAirportName(departure.out_to || '');
|
||||||
const time = convertToLocalTime(departure.etd);
|
|
||||||
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
|
||||||
|
|
||||||
return `
|
let timeDisplay, sortTime;
|
||||||
<tr>
|
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||||
<td>${aircraftDisplay}</td>
|
const time = convertToLocalTime(departure.departed_dt);
|
||||||
<td>${toDisplay}</td>
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||||
<td>${timeDisplay}</td>
|
sortTime = departure.departed_dt;
|
||||||
</tr>
|
} else {
|
||||||
`;
|
const time = convertToLocalTime(departure.etd);
|
||||||
|
timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
||||||
|
sortTime = departure.etd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html = `<tr><td>${aircraftDisplay}</td><td>${toDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
|
sortKey = isDeparted ? `1-${sortTime}` : `0-${sortTime}`; // 0=live, 1=completed
|
||||||
} else {
|
} else {
|
||||||
// PPR
|
// PPR
|
||||||
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
||||||
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
||||||
const toDisplay = await getAirportName(departure.out_to || '');
|
const toDisplay = await getAirportName(departure.out_to || '');
|
||||||
|
|
||||||
// Show departed time if available, otherwise ETD
|
let timeDisplay, sortTime;
|
||||||
let timeDisplay;
|
|
||||||
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||||
const time = convertToLocalTime(departure.departed_dt);
|
const time = convertToLocalTime(departure.departed_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||||
|
sortTime = departure.departed_dt;
|
||||||
} else {
|
} else {
|
||||||
timeDisplay = convertToLocalTime(departure.etd);
|
timeDisplay = convertToLocalTime(departure.etd);
|
||||||
|
sortTime = departure.etd;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(toDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = isDeparted ? `1-${sortTime}` : `0-${sortTime}`;
|
||||||
<td>${aircraftDisplay}</td>
|
|
||||||
<td>${escapeHtml(toDisplay)}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { html, sortKey, isDeparted };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tbody.innerHTML = rows.join('');
|
// Sort: live flights first (0) by ETD ascending, then completed flights (1) by time descending
|
||||||
|
rowsWithData.sort((a, b) => {
|
||||||
|
const aParts = a.sortKey.split('-');
|
||||||
|
const bParts = b.sortKey.split('-');
|
||||||
|
const aType = parseInt(aParts[0]);
|
||||||
|
const bType = parseInt(bParts[0]);
|
||||||
|
|
||||||
|
if (aType !== bType) return aType - bType; // Live before completed
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
if (aType === 0) {
|
||||||
|
// Live flights: ascending (earliest first)
|
||||||
|
return aParts[1].localeCompare(bParts[1]);
|
||||||
|
} else {
|
||||||
|
// Completed flights: descending (most recent first)
|
||||||
|
return bParts[1].localeCompare(aParts[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = rowsWithData.map(r => r.html).join('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading departures:', error);
|
console.error('Error loading departures:', error);
|
||||||
@@ -484,6 +849,9 @@
|
|||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
window.addEventListener('load', function() {
|
window.addEventListener('load', function() {
|
||||||
|
// Initialize Christmas mode
|
||||||
|
initChristmasMode();
|
||||||
|
|
||||||
loadArrivals();
|
loadArrivals();
|
||||||
loadDepartures();
|
loadDepartures();
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
|||||||
typeFieldId = 'local_type';
|
typeFieldId = 'local_type';
|
||||||
} else if (fieldId === 'book_in_registration') {
|
} else if (fieldId === 'book_in_registration') {
|
||||||
typeFieldId = 'book_in_type';
|
typeFieldId = 'book_in_type';
|
||||||
|
} else if (fieldId === 'overflight_registration') {
|
||||||
|
typeFieldId = 'overflight_type';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeFieldId) {
|
if (typeFieldId) {
|
||||||
@@ -361,12 +363,38 @@ function initializeLookups() {
|
|||||||
);
|
);
|
||||||
lookupManager.register('book-in-arrival-airport', bookInArrivalAirportLookup);
|
lookupManager.register('book-in-arrival-airport', bookInArrivalAirportLookup);
|
||||||
|
|
||||||
|
const overflightAircraftLookup = createLookup(
|
||||||
|
'overflight_registration',
|
||||||
|
'overflight-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-aircraft', overflightAircraftLookup);
|
||||||
|
|
||||||
|
const overflightDepartureLookup = createLookup(
|
||||||
|
'overflight_departure_airfield',
|
||||||
|
'overflight-departure-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-departure', overflightDepartureLookup);
|
||||||
|
|
||||||
|
const overflightDestinationLookup = createLookup(
|
||||||
|
'overflight_destination_airfield',
|
||||||
|
'overflight-destination-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('overflight-destination', overflightDestinationLookup);
|
||||||
|
|
||||||
// Attach keyboard handlers to airport input fields
|
// Attach keyboard handlers to airport input fields
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
|
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
|
||||||
if (departureAirportLookup.attachKeyboardHandler) departureAirportLookup.attachKeyboardHandler();
|
if (departureAirportLookup.attachKeyboardHandler) departureAirportLookup.attachKeyboardHandler();
|
||||||
if (localOutToLookup.attachKeyboardHandler) localOutToLookup.attachKeyboardHandler();
|
if (localOutToLookup.attachKeyboardHandler) localOutToLookup.attachKeyboardHandler();
|
||||||
if (bookInArrivalAirportLookup.attachKeyboardHandler) bookInArrivalAirportLookup.attachKeyboardHandler();
|
if (bookInArrivalAirportLookup.attachKeyboardHandler) bookInArrivalAirportLookup.attachKeyboardHandler();
|
||||||
|
if (overflightDepartureLookup.attachKeyboardHandler) overflightDepartureLookup.attachKeyboardHandler();
|
||||||
|
if (overflightDestinationLookup.attachKeyboardHandler) overflightDestinationLookup.attachKeyboardHandler();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -384,7 +384,6 @@
|
|||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
<option value="100LL">100LL</option>
|
<option value="100LL">100LL</option>
|
||||||
<option value="JET A1">JET A1</option>
|
<option value="JET A1">JET A1</option>
|
||||||
<option value="FULL">Full Tanks</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
713
web/reports.html
713
web/reports.html
@@ -22,23 +22,32 @@
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.title h1 {
|
.title h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-buttons {
|
.menu-buttons {
|
||||||
|
order: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar .user-info {
|
.top-bar .user-info {
|
||||||
|
order: 3;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -227,6 +236,45 @@
|
|||||||
.status.canceled { background: #ffebee; color: #d32f2f; }
|
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||||
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
||||||
|
|
||||||
|
.summary-box {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
@@ -313,14 +361,14 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<div class="title">
|
|
||||||
<h1>📊 PPR Reports</h1>
|
|
||||||
</div>
|
|
||||||
<div class="menu-buttons">
|
<div class="menu-buttons">
|
||||||
<button class="btn btn-secondary" onclick="window.location.href='admin.html'">
|
<button class="btn btn-secondary" onclick="window.location.href='admin'">
|
||||||
← Back to Admin
|
← Back to Admin
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="title">
|
||||||
|
<h1 id="tower-title">📊 PPR Reports</h1>
|
||||||
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
Logged in as: <span id="current-user">Loading...</span> |
|
Logged in as: <span id="current-user">Loading...</span> |
|
||||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||||
@@ -330,18 +378,26 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Filters Section -->
|
<!-- Filters Section -->
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<div class="filters-grid">
|
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
|
||||||
<div class="filter-group">
|
<!-- Quick Filter Buttons -->
|
||||||
<label for="date-from">Date From:</label>
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="date" id="date-from">
|
<button class="btn btn-primary" id="filter-today" onclick="setDateRangeToday()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📅 Today</button>
|
||||||
|
<button class="btn btn-secondary" id="filter-week" onclick="setDateRangeThisWeek()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📆 This Week</button>
|
||||||
|
<button class="btn btn-secondary" id="filter-month" onclick="setDateRangeThisMonth()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📊 This Month</button>
|
||||||
|
<button class="btn btn-secondary" id="filter-custom" onclick="toggleCustomRange()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📋 Custom Range</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="date-to">Date To:</label>
|
<!-- Custom Date Range (hidden by default) -->
|
||||||
<input type="date" id="date-to">
|
<div id="custom-range-container" style="display: none; display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<input type="date" id="date-from" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
<span style="font-weight: 600; color: #666;">to</span>
|
||||||
|
<input type="date" id="date-to" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="status-filter">Status:</label>
|
<!-- Status Filter -->
|
||||||
<select id="status-filter">
|
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
|
||||||
|
<label for="status-filter" style="font-weight: 600; font-size: 0.85rem; color: #555;">Status:</label>
|
||||||
|
<select id="status-filter" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="NEW">New</option>
|
<option value="NEW">New</option>
|
||||||
<option value="CONFIRMED">Confirmed</option>
|
<option value="CONFIRMED">Confirmed</option>
|
||||||
@@ -351,23 +407,91 @@
|
|||||||
<option value="DELETED">Deleted</option>
|
<option value="DELETED">Deleted</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="search-input">Search:</label>
|
<!-- Search Input -->
|
||||||
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
|
<div style="flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 0.3rem;">
|
||||||
|
<label for="search-input" style="font-weight: 600; font-size: 0.85rem; color: #555;">Search:</label>
|
||||||
|
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..." style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-actions">
|
|
||||||
<button class="btn btn-primary" onclick="loadReports()">
|
<!-- Action Buttons -->
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-primary" onclick="loadReports()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
|
||||||
🔍 Search
|
🔍 Search
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" onclick="clearFilters()">
|
<button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
|
||||||
🗑️ Clear
|
🗑️ Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Box -->
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="summary-title">📊 Movements Summary</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center;">
|
||||||
|
<div class="summary-grid">
|
||||||
|
<!-- PPR Section -->
|
||||||
|
<div style="grid-column: 1/-1; padding-bottom: 0.8rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
|
||||||
|
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">PPR Movements</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem;">
|
||||||
|
<div class="summary-item" style="padding: 0.4rem;">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-arrivals">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="padding: 0.4rem;">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-departures">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
|
||||||
|
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-total">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Non-PPR Section -->
|
||||||
|
<div style="grid-column: 1/-1; padding-top: 0.8rem;">
|
||||||
|
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">Non-PPR Movements</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.8rem;">
|
||||||
|
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('LOCAL')">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Local</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="local-flights-movements">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('CIRCUIT')">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Circuits</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="circuits-movements">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('ARRIVAL')">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-arrivals">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('DEPARTURE')">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
|
||||||
|
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
|
||||||
|
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
|
||||||
|
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-total">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grand Total - positioned on the right -->
|
||||||
|
<div style="text-align: center; padding: 1rem; background: rgba(255,215,0,0.05); border-radius: 8px; border-left: 4px solid #ffd700; min-width: 120px;">
|
||||||
|
<div style="font-size: 0.75rem; opacity: 0.85; margin-bottom: 0.2rem;">GRAND TOTAL</div>
|
||||||
|
<div style="font-size: 2.2rem; font-weight: 700;" id="grand-total-movements">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reports Table -->
|
<!-- Reports Table -->
|
||||||
<div class="reports-table">
|
<div class="reports-table" id="ppr-reports-section">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>PPR Records</strong>
|
<strong>PPR Records</strong>
|
||||||
@@ -422,6 +546,54 @@
|
|||||||
<p>No records match your current filters.</p>
|
<p>No records match your current filters.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Other Flights Table -->
|
||||||
|
<div class="reports-table" style="margin-top: 2rem;">
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<strong>Other Flights</strong>
|
||||||
|
<div class="table-info" id="other-flights-info">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="export-buttons">
|
||||||
|
<button class="btn btn-success" onclick="exportOtherFlightsToCSV()">
|
||||||
|
📊 Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="other-flights-loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Loading flights...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="other-flights-table-content" style="display: none;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Aircraft</th>
|
||||||
|
<th>Aircraft Type</th>
|
||||||
|
<th>Callsign</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>ETA / ETD / Called</th>
|
||||||
|
<th>Landed / Departed / QSY</th>
|
||||||
|
<th>Circuits</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="other-flights-table-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="other-flights-no-data" class="no-data" style="display: none;">
|
||||||
|
<h3>No other flights found</h3>
|
||||||
|
<p>No flights match your current filters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Notification -->
|
<!-- Success Notification -->
|
||||||
@@ -431,22 +603,156 @@
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let accessToken = null;
|
let accessToken = null;
|
||||||
let currentPPRs = []; // Store current results for export
|
let currentPPRs = []; // Store current results for export
|
||||||
|
let currentOtherFlights = []; // Store other flights for export
|
||||||
|
let otherFlightsFilterType = null; // Track which non-PPR flight type is selected for filtering
|
||||||
|
|
||||||
|
// Load UI configuration from API
|
||||||
|
async function loadUIConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/public/config');
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
// Update tower title
|
||||||
|
const titleElement = document.getElementById('tower-title');
|
||||||
|
if (titleElement && config.tag) {
|
||||||
|
titleElement.innerHTML = `📊 Reports ${config.tag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update top bar gradient
|
||||||
|
const topBar = document.querySelector('.top-bar');
|
||||||
|
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
|
||||||
|
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
if (config.tag) {
|
||||||
|
document.title = `PPR Reports - ${config.tag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally indicate environment (e.g., add to title if not production)
|
||||||
|
if (config.environment && config.environment !== 'production') {
|
||||||
|
const envIndicator = ` (${config.environment.toUpperCase()})`;
|
||||||
|
if (titleElement) {
|
||||||
|
titleElement.innerHTML += envIndicator;
|
||||||
|
}
|
||||||
|
if (document.title) {
|
||||||
|
document.title += envIndicator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load UI config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the page
|
// Initialize the page
|
||||||
async function initializePage() {
|
async function initializePage() {
|
||||||
|
loadUIConfig(); // Load UI configuration first
|
||||||
await initializeAuth();
|
await initializeAuth();
|
||||||
setupDefaultDateRange();
|
setupDefaultDateRange();
|
||||||
await loadReports();
|
await loadReports();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default date range to current month
|
// Set default date range to today
|
||||||
function setupDefaultDateRange() {
|
function setupDefaultDateRange() {
|
||||||
|
setDateRangeToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle custom date range picker
|
||||||
|
function toggleCustomRange() {
|
||||||
|
const container = document.getElementById('custom-range-container');
|
||||||
|
const customBtn = document.getElementById('filter-custom');
|
||||||
|
|
||||||
|
const isVisible = container.style.display !== 'none';
|
||||||
|
container.style.display = isVisible ? 'none' : 'flex';
|
||||||
|
|
||||||
|
// Update button style
|
||||||
|
if (isVisible) {
|
||||||
|
customBtn.classList.remove('btn-primary');
|
||||||
|
customBtn.classList.add('btn-secondary');
|
||||||
|
} else {
|
||||||
|
customBtn.classList.remove('btn-secondary');
|
||||||
|
customBtn.classList.add('btn-primary');
|
||||||
|
// Focus on the first date input when opening
|
||||||
|
document.getElementById('date-from').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date range to today
|
||||||
|
function setDateRangeToday() {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('date-from').value = today;
|
||||||
|
document.getElementById('date-to').value = today;
|
||||||
|
|
||||||
|
// Hide custom range picker if it's open
|
||||||
|
document.getElementById('custom-range-container').style.display = 'none';
|
||||||
|
|
||||||
|
updateFilterButtonStyles('today');
|
||||||
|
loadReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date range to this week (Monday to Sunday)
|
||||||
|
function setDateRangeThisWeek() {
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||||
|
const monday = new Date(now.setDate(diff));
|
||||||
|
const sunday = new Date(now.setDate(diff + 6));
|
||||||
|
|
||||||
|
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
|
||||||
|
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Hide custom range picker if it's open
|
||||||
|
document.getElementById('custom-range-container').style.display = 'none';
|
||||||
|
|
||||||
|
updateFilterButtonStyles('week');
|
||||||
|
loadReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date range to this month
|
||||||
|
function setDateRangeThisMonth() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Hide custom range picker if it's open
|
||||||
|
document.getElementById('custom-range-container').style.display = 'none';
|
||||||
|
|
||||||
|
updateFilterButtonStyles('month');
|
||||||
|
loadReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button styles to show which filter is active
|
||||||
|
function updateFilterButtonStyles(activeFilter) {
|
||||||
|
const todayBtn = document.getElementById('filter-today');
|
||||||
|
const weekBtn = document.getElementById('filter-week');
|
||||||
|
const monthBtn = document.getElementById('filter-month');
|
||||||
|
|
||||||
|
// Reset all buttons
|
||||||
|
[todayBtn, weekBtn, monthBtn].forEach(btn => {
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight active button
|
||||||
|
switch(activeFilter) {
|
||||||
|
case 'today':
|
||||||
|
todayBtn.classList.remove('btn-secondary');
|
||||||
|
todayBtn.classList.add('btn-primary');
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
weekBtn.classList.remove('btn-secondary');
|
||||||
|
weekBtn.classList.add('btn-primary');
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
monthBtn.classList.remove('btn-secondary');
|
||||||
|
monthBtn.classList.add('btn-primary');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication management
|
// Authentication management
|
||||||
@@ -466,7 +772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No valid cached token, redirect to admin
|
// No valid cached token, redirect to admin
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -475,13 +781,13 @@
|
|||||||
localStorage.removeItem('ppr_token_expiry');
|
localStorage.removeItem('ppr_token_expiry');
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced fetch wrapper with token expiry handling
|
// Enhanced fetch wrapper with token expiry handling
|
||||||
async function authenticatedFetch(url, options = {}) {
|
async function authenticatedFetch(url, options = {}) {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
throw new Error('No access token available');
|
throw new Error('No access token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +814,9 @@
|
|||||||
document.getElementById('reports-loading').style.display = 'block';
|
document.getElementById('reports-loading').style.display = 'block';
|
||||||
document.getElementById('reports-table-content').style.display = 'none';
|
document.getElementById('reports-table-content').style.display = 'none';
|
||||||
document.getElementById('reports-no-data').style.display = 'none';
|
document.getElementById('reports-no-data').style.display = 'none';
|
||||||
|
document.getElementById('other-flights-loading').style.display = 'block';
|
||||||
|
document.getElementById('other-flights-table-content').style.display = 'none';
|
||||||
|
document.getElementById('other-flights-no-data').style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dateFrom = document.getElementById('date-from').value;
|
const dateFrom = document.getElementById('date-from').value;
|
||||||
@@ -521,13 +830,20 @@
|
|||||||
if (dateTo) url += `&date_to=${dateTo}`;
|
if (dateTo) url += `&date_to=${dateTo}`;
|
||||||
if (status) url += `&status=${status}`;
|
if (status) url += `&status=${status}`;
|
||||||
|
|
||||||
const response = await authenticatedFetch(url);
|
// Fetch all data in parallel
|
||||||
|
const [pprResponse, arrivalsResponse, departuresResponse, localFlightsResponse, overflightsResponse] = await Promise.all([
|
||||||
|
authenticatedFetch(url),
|
||||||
|
authenticatedFetch(`/api/v1/arrivals/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||||
|
authenticatedFetch(`/api/v1/departures/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||||
|
authenticatedFetch(`/api/v1/local-flights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||||
|
authenticatedFetch(`/api/v1/overflights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`)
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!pprResponse.ok) {
|
||||||
throw new Error('Failed to fetch PPR records');
|
throw new Error('Failed to fetch PPR records');
|
||||||
}
|
}
|
||||||
|
|
||||||
let pprs = await response.json();
|
let pprs = await pprResponse.json();
|
||||||
|
|
||||||
// Apply client-side search filtering
|
// Apply client-side search filtering
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -543,6 +859,78 @@
|
|||||||
|
|
||||||
currentPPRs = pprs; // Store for export
|
currentPPRs = pprs; // Store for export
|
||||||
displayReports(pprs);
|
displayReports(pprs);
|
||||||
|
|
||||||
|
// Process other flights
|
||||||
|
let otherFlights = [];
|
||||||
|
|
||||||
|
if (arrivalsResponse.ok) {
|
||||||
|
const arrivals = await arrivalsResponse.json();
|
||||||
|
otherFlights.push(...arrivals.map(f => ({
|
||||||
|
...f,
|
||||||
|
flightType: 'ARRIVAL',
|
||||||
|
aircraft_type: f.type,
|
||||||
|
timeField: f.eta || f.landed_dt,
|
||||||
|
fromField: f.in_from,
|
||||||
|
toField: 'EGFH'
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departuresResponse.ok) {
|
||||||
|
const departures = await departuresResponse.json();
|
||||||
|
otherFlights.push(...departures.map(f => ({
|
||||||
|
...f,
|
||||||
|
flightType: 'DEPARTURE',
|
||||||
|
aircraft_type: f.type,
|
||||||
|
timeField: f.etd || f.departed_dt,
|
||||||
|
fromField: 'EGFH',
|
||||||
|
toField: f.out_to
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localFlightsResponse.ok) {
|
||||||
|
const localFlights = await localFlightsResponse.json();
|
||||||
|
otherFlights.push(...localFlights.map(f => ({
|
||||||
|
...f,
|
||||||
|
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
||||||
|
aircraft_type: f.type,
|
||||||
|
circuits: f.circuits,
|
||||||
|
timeField: f.departed_dt,
|
||||||
|
fromField: 'EGFH',
|
||||||
|
toField: 'EGFH'
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overflightsResponse.ok) {
|
||||||
|
const overflights = await overflightsResponse.json();
|
||||||
|
otherFlights.push(...overflights.map(f => ({
|
||||||
|
...f,
|
||||||
|
flightType: 'OVERFLIGHT',
|
||||||
|
aircraft_type: f.type,
|
||||||
|
circuits: null,
|
||||||
|
timeField: f.call_dt,
|
||||||
|
fromField: f.departure_airfield,
|
||||||
|
toField: f.destination_airfield,
|
||||||
|
callsign: f.registration
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filtering to other flights
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
otherFlights = otherFlights.filter(f =>
|
||||||
|
(f.registration && f.registration.toLowerCase().includes(searchLower)) ||
|
||||||
|
(f.callsign && f.callsign.toLowerCase().includes(searchLower)) ||
|
||||||
|
(f.fromField && f.fromField.toLowerCase().includes(searchLower)) ||
|
||||||
|
(f.toField && f.toField.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOtherFlights = otherFlights;
|
||||||
|
displayOtherFlights(otherFlights);
|
||||||
|
|
||||||
|
// Calculate and display movements summary
|
||||||
|
calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading reports:', error);
|
console.error('Error loading reports:', error);
|
||||||
if (error.message !== 'Session expired. Please log in again.') {
|
if (error.message !== 'Session expired. Please log in again.') {
|
||||||
@@ -551,6 +939,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('reports-loading').style.display = 'none';
|
document.getElementById('reports-loading').style.display = 'none';
|
||||||
|
document.getElementById('other-flights-loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and display movements summary
|
||||||
|
function calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo) {
|
||||||
|
let pprArrivals = 0; // PPR landings
|
||||||
|
let pprDepartures = 0; // PPR takeoffs
|
||||||
|
let localFlightsMovements = 0;
|
||||||
|
let circuitsMovements = 0;
|
||||||
|
let nonPprArrivals = 0;
|
||||||
|
let nonPprDepartures = 0;
|
||||||
|
|
||||||
|
// Format date range for display
|
||||||
|
let dateRangeText = '';
|
||||||
|
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||||
|
// Single day
|
||||||
|
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||||
|
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||||
|
} else if (dateFrom && dateTo) {
|
||||||
|
// Date range
|
||||||
|
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
||||||
|
const toDate = new Date(dateTo + 'T00:00:00Z');
|
||||||
|
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
dateRangeText = `for ${fromText} to ${toText}`;
|
||||||
|
} else if (dateFrom) {
|
||||||
|
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||||
|
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||||
|
} else if (dateTo) {
|
||||||
|
const date = new Date(dateTo + 'T00:00:00Z');
|
||||||
|
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary title with date range
|
||||||
|
const summaryTitle = document.querySelector('.summary-title');
|
||||||
|
if (summaryTitle) {
|
||||||
|
summaryTitle.textContent = `📊 Movements Summary ${dateRangeText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PPR movements (excluding CANCELED):
|
||||||
|
// - LANDED = 1 arrival (landing)
|
||||||
|
// - DEPARTED = 1 departure + 1 arrival (because departure implies a prior landing)
|
||||||
|
pprs.filter(ppr => ppr.status !== 'CANCELED').forEach(ppr => {
|
||||||
|
if (ppr.status === 'LANDED') {
|
||||||
|
pprArrivals += 1;
|
||||||
|
} else if (ppr.status === 'DEPARTED') {
|
||||||
|
pprDepartures += 1;
|
||||||
|
pprArrivals += 1; // Each departure implies a landing happened
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other flights movements (excluding CANCELLED)
|
||||||
|
let overflightCount = 0;
|
||||||
|
otherFlights.filter(flight => flight.status !== 'CANCELLED').forEach(flight => {
|
||||||
|
if (flight.flightType === 'ARRIVAL') {
|
||||||
|
nonPprArrivals += 1;
|
||||||
|
} else if (flight.flightType === 'DEPARTURE') {
|
||||||
|
nonPprDepartures += 1;
|
||||||
|
} else if (flight.flightType === 'LOCAL') {
|
||||||
|
// 2 movements (takeoff + landing) for the flight itself
|
||||||
|
localFlightsMovements += 2;
|
||||||
|
} else if (flight.flightType === 'CIRCUIT') {
|
||||||
|
// 2 movements (takeoff + landing) plus the circuit count
|
||||||
|
const circuits = flight.circuits || 0;
|
||||||
|
circuitsMovements += 2 + circuits;
|
||||||
|
} else if (flight.flightType === 'OVERFLIGHT') {
|
||||||
|
// 1 movement for each overflight (they're just talking to tower)
|
||||||
|
overflightCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pprTotal = pprArrivals + pprDepartures;
|
||||||
|
const nonPprTotal = localFlightsMovements + circuitsMovements + nonPprArrivals + nonPprDepartures + overflightCount;
|
||||||
|
const grandTotal = pprTotal + nonPprTotal;
|
||||||
|
|
||||||
|
// Update the summary display
|
||||||
|
document.getElementById('ppr-arrivals').textContent = pprArrivals;
|
||||||
|
document.getElementById('ppr-departures').textContent = pprDepartures;
|
||||||
|
document.getElementById('ppr-total').textContent = pprTotal;
|
||||||
|
document.getElementById('overflights-count').textContent = overflightCount;
|
||||||
|
|
||||||
|
document.getElementById('local-flights-movements').textContent = localFlightsMovements;
|
||||||
|
document.getElementById('circuits-movements').textContent = circuitsMovements;
|
||||||
|
document.getElementById('non-ppr-arrivals').textContent = nonPprArrivals;
|
||||||
|
document.getElementById('non-ppr-departures').textContent = nonPprDepartures;
|
||||||
|
document.getElementById('non-ppr-total').textContent = nonPprTotal;
|
||||||
|
|
||||||
|
document.getElementById('grand-total-movements').textContent = grandTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display reports in table
|
// Display reports in table
|
||||||
@@ -615,6 +1091,156 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter other flights by type
|
||||||
|
function filterOtherFlights(flightType) {
|
||||||
|
// Toggle filter if clicking the same type
|
||||||
|
if (otherFlightsFilterType === flightType) {
|
||||||
|
otherFlightsFilterType = null;
|
||||||
|
} else {
|
||||||
|
otherFlightsFilterType = flightType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide PPR section based on filter
|
||||||
|
const pprSection = document.getElementById('ppr-reports-section');
|
||||||
|
if (pprSection) {
|
||||||
|
pprSection.style.display = otherFlightsFilterType ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visual indication of active filter
|
||||||
|
updateFilterIndicators();
|
||||||
|
|
||||||
|
// Re-display flights with new filter
|
||||||
|
displayOtherFlights(currentOtherFlights);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visual indicators for active filter
|
||||||
|
function updateFilterIndicators() {
|
||||||
|
// Select all clickable non-PPR summary items (those with onclick attribute)
|
||||||
|
const summaryItems = document.querySelectorAll('.summary-item[onclick*="filterOtherFlights"]');
|
||||||
|
summaryItems.forEach(item => {
|
||||||
|
item.style.opacity = '1';
|
||||||
|
item.style.borderLeftColor = '';
|
||||||
|
item.style.borderLeftWidth = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (otherFlightsFilterType) {
|
||||||
|
// Get the ID of the selected filter's summary item
|
||||||
|
let selectedId = '';
|
||||||
|
switch(otherFlightsFilterType) {
|
||||||
|
case 'LOCAL':
|
||||||
|
selectedId = 'local-flights-movements';
|
||||||
|
break;
|
||||||
|
case 'CIRCUIT':
|
||||||
|
selectedId = 'circuits-movements';
|
||||||
|
break;
|
||||||
|
case 'ARRIVAL':
|
||||||
|
selectedId = 'non-ppr-arrivals';
|
||||||
|
break;
|
||||||
|
case 'DEPARTURE':
|
||||||
|
selectedId = 'non-ppr-departures';
|
||||||
|
break;
|
||||||
|
case 'OVERFLIGHT':
|
||||||
|
selectedId = 'overflights-count';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and highlight the selected item
|
||||||
|
if (selectedId) {
|
||||||
|
const selectedElement = document.getElementById(selectedId);
|
||||||
|
if (selectedElement) {
|
||||||
|
const summaryItem = selectedElement.closest('.summary-item');
|
||||||
|
if (summaryItem) {
|
||||||
|
summaryItem.style.borderLeftColor = '#4CAF50';
|
||||||
|
summaryItem.style.borderLeftWidth = '4px';
|
||||||
|
summaryItem.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dim other items that are clickable (non-PPR items)
|
||||||
|
const allSummaryItems = document.querySelectorAll('.summary-item[onclick]');
|
||||||
|
allSummaryItems.forEach(item => {
|
||||||
|
if (item.querySelector('#' + selectedId) === null) {
|
||||||
|
item.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display other flights in table
|
||||||
|
function displayOtherFlights(flights) {
|
||||||
|
const tbody = document.getElementById('other-flights-table-body');
|
||||||
|
const tableInfo = document.getElementById('other-flights-info');
|
||||||
|
|
||||||
|
// Apply filter if one is selected
|
||||||
|
let filteredFlights = flights;
|
||||||
|
if (otherFlightsFilterType) {
|
||||||
|
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
|
||||||
|
}
|
||||||
|
|
||||||
|
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
|
||||||
|
|
||||||
|
if (filteredFlights.length === 0) {
|
||||||
|
document.getElementById('other-flights-no-data').style.display = 'block';
|
||||||
|
document.getElementById('other-flights-table-content').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time field (ascending)
|
||||||
|
filteredFlights.sort((a, b) => {
|
||||||
|
const aTime = a.timeField;
|
||||||
|
const bTime = b.timeField;
|
||||||
|
if (!aTime) return 1;
|
||||||
|
if (!bTime) return -1;
|
||||||
|
return new Date(aTime) - new Date(bTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
document.getElementById('other-flights-table-content').style.display = 'block';
|
||||||
|
document.getElementById('other-flights-no-data').style.display = 'none';
|
||||||
|
|
||||||
|
for (const flight of filteredFlights) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
const typeLabel = flight.flightType;
|
||||||
|
const registration = flight.registration || '-';
|
||||||
|
const aircraftType = flight.aircraft_type || '-';
|
||||||
|
const callsign = flight.callsign || '-';
|
||||||
|
const from = flight.fromField || '-';
|
||||||
|
const to = flight.toField || '-';
|
||||||
|
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
|
||||||
|
|
||||||
|
// Different display for different flight types
|
||||||
|
let actualDisplay = '-';
|
||||||
|
if (flight.flightType === 'ARRIVAL') {
|
||||||
|
actualDisplay = flight.landed_dt ? formatDateTime(flight.landed_dt) : '-';
|
||||||
|
} else if (flight.flightType === 'OVERFLIGHT') {
|
||||||
|
// For overflights, show qsy_dt (frequency change time)
|
||||||
|
actualDisplay = flight.qsy_dt ? formatDateTime(flight.qsy_dt) : '-';
|
||||||
|
} else {
|
||||||
|
actualDisplay = flight.departed_dt ? formatDateTime(flight.departed_dt) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
|
||||||
|
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><span class="status ${status.toLowerCase()}">${status}</span></td>
|
||||||
|
<td><strong>${typeLabel}</strong></td>
|
||||||
|
<td>${registration}</td>
|
||||||
|
<td>${aircraftType}</td>
|
||||||
|
<td>${callsign}</td>
|
||||||
|
<td>${from}</td>
|
||||||
|
<td>${to}</td>
|
||||||
|
<td>${timeDisplay}</td>
|
||||||
|
<td>${actualDisplay}</td>
|
||||||
|
<td>${circuits}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(dateStr) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
let utcDateStr = dateStr;
|
let utcDateStr = dateStr;
|
||||||
@@ -683,6 +1309,35 @@
|
|||||||
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportOtherFlightsToCSV() {
|
||||||
|
if (currentOtherFlights.length === 0) {
|
||||||
|
showNotification('No data to export', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
|
||||||
|
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvData = currentOtherFlights.map(flight => [
|
||||||
|
flight.flightType,
|
||||||
|
flight.registration || '',
|
||||||
|
flight.aircraft_type || '',
|
||||||
|
flight.callsign || '',
|
||||||
|
flight.fromField || '',
|
||||||
|
flight.toField || '',
|
||||||
|
flight.timeField ? formatDateTime(flight.timeField) : '',
|
||||||
|
flight.flightType === 'ARRIVAL'
|
||||||
|
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
|
||||||
|
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
|
||||||
|
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
|
||||||
|
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
downloadCSV(headers, csvData, 'other_flights_reports.csv');
|
||||||
|
}
|
||||||
|
|
||||||
function downloadCSV(headers, data, filename) {
|
function downloadCSV(headers, data, filename) {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
headers.join(','),
|
headers.join(','),
|
||||||
|
|||||||
Reference in New Issue
Block a user