Compare commits

...

16 Commits

Author SHA1 Message Date
jamesp 7b2de645db Major refactor WIP 2026-04-03 11:13:44 -04:00
jamesp dee58e0aae Journaling improvements 2026-04-03 03:57:20 -04:00
jamesp 2dce14507b Booking form fixes 2026-03-25 17:25:26 -04:00
jamesp 9867156334 Many more states WIP 2026-03-25 13:16:36 -04:00
jamesp eb2321ef40 Before refactor 2026-03-24 13:35:29 -04:00
jamesp bb6597ff76 Major WIP state machine 2026-03-24 11:22:20 -04:00
jamesp 423023d3d9 adding flow doc 2026-03-24 04:48:06 -04:00
jamesp fd0e521186 List and edit user aircraft 2026-03-23 13:09:49 -04:00
jamesp d2c9bc0370 Unknown type supprt 2026-03-23 12:47:08 -04:00
jamesp bddbe1451f Little tidy 2026-02-20 16:50:03 -05:00
jamesp 785562407a localStorage for booking out 2026-02-20 16:42:06 -05:00
jamesp 5bb229ad78 Oops 2026-02-20 12:23:09 -05:00
jamesp 8a2dd5544c ignore QR 2026-02-20 12:21:12 -05:00
jamesp 3a4085afc6 Booking out QR code 2026-02-20 12:19:21 -05:00
jamesp a43cf9b732 Merge pull request 'Pilot self-bookout' (#6) from local-flights into main
Reviewed-on: #6
2026-02-20 11:59:25 -05:00
jamesp 7f4e4a8459 Pilot self-bookout 2026-02-20 11:52:43 -05:00
60 changed files with 11403 additions and 3720 deletions
+2
View File
@@ -1,3 +1,5 @@
web/assets/booking-qr.png
# Python
__pycache__/
*.py[cod]
+11
View File
@@ -0,0 +1,11 @@
TODO
Implement mark's 'tick off the PPRs' in the old admin screen
Define schema for 'movements' table. We generate movement records as they happen so as not to reply on maths
Flow to create an arrival and maybe departure from a PPR. Perhaps we need a correlation column somewhere
Ability to add a position report to a strip
Improve journaling
+2 -1
View File
@@ -3,11 +3,12 @@ FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
# Install system dependencies including qrencode
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
qrencode \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
@@ -0,0 +1,82 @@
"""Add public booking support with submitted_via and pilot_email columns
Revision ID: 003_public_booking
Revises: 002_local_flights
Create Date: 2026-02-20 12:00:00.000000
This migration adds support for public flight booking by adding:
- submitted_via enum field to track ADMIN vs PUBLIC submissions
- pilot_email field to store contact info for public submissions
- Indexes on submitted_via for filtering queries
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_public_booking'
down_revision = '002_local_flights'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Add public booking support columns to local_flights, departures, and arrivals tables.
"""
# Create the SubmissionSource enum type
submission_source_enum = sa.Enum('ADMIN', 'PUBLIC', name='submissionsource')
# Add submitted_via and pilot_email to local_flights table
op.add_column('local_flights', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('local_flights', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on local_flights
op.create_index('idx_lf_submitted_via', 'local_flights', ['submitted_via'])
op.create_index('idx_lf_pilot_email', 'local_flights', ['pilot_email'])
# Add submitted_via and pilot_email to departures table
op.add_column('departures', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('departures', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on departures
op.create_index('idx_dep_submitted_via', 'departures', ['submitted_via'])
op.create_index('idx_dep_pilot_email', 'departures', ['pilot_email'])
# Add submitted_via and pilot_email to arrivals table
op.add_column('arrivals', sa.Column('submitted_via', submission_source_enum, nullable=False, server_default='ADMIN'))
op.add_column('arrivals', sa.Column('pilot_email', sa.String(length=128), nullable=True))
# Add indexes for submitted_via and pilot_email on arrivals
op.create_index('idx_arr_submitted_via', 'arrivals', ['submitted_via'])
op.create_index('idx_arr_pilot_email', 'arrivals', ['pilot_email'])
def downgrade() -> None:
"""
Remove the submitted_via and pilot_email columns from local_flights, departures, and arrivals tables.
"""
# Drop indexes first
op.drop_index('idx_lf_submitted_via', table_name='local_flights')
op.drop_index('idx_lf_pilot_email', table_name='local_flights')
op.drop_index('idx_dep_submitted_via', table_name='departures')
op.drop_index('idx_dep_pilot_email', table_name='departures')
op.drop_index('idx_arr_submitted_via', table_name='arrivals')
op.drop_index('idx_arr_pilot_email', table_name='arrivals')
# Drop columns from local_flights
op.drop_column('local_flights', 'pilot_email')
op.drop_column('local_flights', 'submitted_via')
# Drop columns from departures
op.drop_column('departures', 'pilot_email')
op.drop_column('departures', 'submitted_via')
# Drop columns from arrivals
op.drop_column('arrivals', 'pilot_email')
op.drop_column('arrivals', 'submitted_via')
# Drop the enum type
op.execute('DROP TYPE IF EXISTS submissionsource')
@@ -0,0 +1,50 @@
"""Add user_aircraft table for user-defined aircraft types
Revision ID: 004_user_aircraft
Revises: 003_public_booking
Create Date: 2026-03-23 12:00:00.000000
This migration adds a user_aircraft table to store aircraft types
that are manually entered by users when not found in the main aircraft database.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004_user_aircraft'
down_revision = '003_public_booking'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Create user_aircraft table for storing user-defined aircraft types.
"""
op.create_table('user_aircraft',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('registration', sa.String(length=25), nullable=False),
sa.Column('type_code', sa.String(length=30), nullable=False),
sa.Column('clean_reg', sa.String(length=25), nullable=False),
sa.Column('created_by', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('registration')
)
# Create indexes
op.create_index('idx_user_aircraft_registration', 'user_aircraft', ['registration'])
op.create_index('idx_user_aircraft_clean_reg', 'user_aircraft', ['clean_reg'])
op.create_index('idx_user_aircraft_created_by', 'user_aircraft', ['created_by'])
def downgrade() -> None:
"""
Drop user_aircraft table.
"""
op.drop_index('idx_user_aircraft_created_by', table_name='user_aircraft')
op.drop_index('idx_user_aircraft_clean_reg', table_name='user_aircraft')
op.drop_index('idx_user_aircraft_registration', table_name='user_aircraft')
op.drop_table('user_aircraft')
@@ -0,0 +1,79 @@
"""Add granular flight states and timestamps
Revision ID: 8adefaee847c
Revises: 004_user_aircraft
Create Date: 2026-03-24 09:09:00.944815
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005_flight_states'
down_revision = '004_user_aircraft'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add GROUND and LOCAL to local_flights status enum
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
# Add timestamp columns to local_flights
op.add_column('local_flights', sa.Column('contact_dt', sa.DateTime(), nullable=True))
op.add_column('local_flights', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
# Add GROUND and ARRIVED to arrivals status enum
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','INBOUND','LANDED','GROUND','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','ARRIVED','CANCELLED')")
# Add timestamp column to arrivals
op.add_column('arrivals', sa.Column('arrived_dt', sa.DateTime(), nullable=True))
# Add GROUND and LOCAL to departures status enum
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CIRCUIT','CIRCUIT_DOWNWIND','CIRCUIT_BASE','CIRCUIT_FINAL','LANDED','CANCELLED')")
# Add timestamp columns to departures
op.add_column('departures', sa.Column('contact_dt', sa.DateTime(), nullable=True))
op.add_column('departures', sa.Column('takeoff_dt', sa.DateTime(), nullable=True))
# Add arrival_id column to circuits table to support circuit logging for arrivals
op.add_column('circuits', sa.Column('arrival_id', sa.BigInteger(), nullable=True))
op.create_foreign_key('fk_circuits_arrival_id', 'circuits', 'arrivals', ['arrival_id'], ['id'], ondelete='CASCADE')
op.create_index('idx_circuit_arrival_id', 'circuits', ['arrival_id'])
def downgrade() -> None:
# Remove arrival_id column from circuits table
op.drop_constraint('fk_circuits_arrival_id', 'circuits', type_='foreignkey')
op.drop_index('idx_circuit_arrival_id', table_name='circuits')
op.drop_column('circuits', 'arrival_id')
# Update departures with new status values to valid old values before modifying enum
op.execute("UPDATE departures SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL')")
# Remove timestamp columns from departures
op.drop_column('departures', 'takeoff_dt')
op.drop_column('departures', 'contact_dt')
# Remove GROUND and LOCAL from departures status enum
op.execute("ALTER TABLE departures MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','CANCELLED')")
# Update arrivals with new status values to valid old values before modifying enum
op.execute("UPDATE arrivals SET status = 'LANDED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT', 'ARRIVED')")
# Remove timestamp column from arrivals
op.drop_column('arrivals', 'arrived_dt')
# Remove GROUND and ARRIVED from arrivals status enum
op.execute("ALTER TABLE arrivals MODIFY COLUMN status ENUM('BOOKED_IN','LANDED','CANCELLED')")
# Update local_flights with new status values to valid old values before modifying enum
op.execute("UPDATE local_flights SET status = 'DEPARTED' WHERE status IN ('GROUND', 'LOCAL', 'CIRCUIT')")
# Remove timestamp columns from local_flights
op.drop_column('local_flights', 'takeoff_dt')
op.drop_column('local_flights', 'contact_dt')
# Remove GROUND and LOCAL from local_flights status enum
op.execute("ALTER TABLE local_flights MODIFY COLUMN status ENUM('BOOKED_OUT','DEPARTED','LANDED','CANCELLED')")
+67
View File
@@ -0,0 +1,67 @@
"""Add movements table
Revision ID: 006_movements
Revises: 005_flight_states
Create Date: 2026-04-03 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import Enum
# revision identifiers, used by Alembic.
revision = '006_movements'
down_revision = '005_flight_states'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create movements table
op.create_table('movements',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('movement_type', sa.Enum('TAKEOFF', 'LANDING', 'OVERFLIGHT', 'GO_AROUND', 'TOUCH_AND_GO', name='movementtype'), nullable=False),
sa.Column('aircraft_registration', sa.String(length=16), nullable=False),
sa.Column('aircraft_type', sa.String(length=32), nullable=True),
sa.Column('callsign', sa.String(length=16), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('entity_type', sa.String(length=50), nullable=False),
sa.Column('entity_id', sa.BigInteger(), nullable=False),
sa.Column('to_location', sa.String(length=64), nullable=True),
sa.Column('from_location', sa.String(length=64), nullable=True),
sa.Column('runway', sa.String(length=10), nullable=True),
sa.Column('wind', sa.String(length=20), nullable=True),
sa.Column('pressure_setting', sa.String(length=20), nullable=True),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('notes', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index('idx_movement_lookup', 'movements', ['entity_type', 'entity_id'], unique=False)
op.create_index('idx_movement_time', 'movements', ['timestamp', 'movement_type'], unique=False)
op.create_index('ix_movements_movement_type', 'movements', ['movement_type'], unique=False)
op.create_index('ix_movements_aircraft_registration', 'movements', ['aircraft_registration'], unique=False)
op.create_index('ix_movements_timestamp', 'movements', ['timestamp'], unique=False)
op.create_index('ix_movements_entity_type', 'movements', ['entity_type'], unique=False)
op.create_index('ix_movements_created_by', 'movements', ['created_by'], unique=False)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_movements_created_by', table_name='movements')
op.drop_index('ix_movements_entity_type', table_name='movements')
op.drop_index('ix_movements_timestamp', table_name='movements')
op.drop_index('ix_movements_aircraft_registration', table_name='movements')
op.drop_index('ix_movements_movement_type', table_name='movements')
op.drop_index('idx_movement_time', table_name='movements')
op.drop_index('idx_movement_lookup', table_name='movements')
# Drop table
op.drop_table('movements')
# Drop enum
op.execute("DROP TYPE IF EXISTS movementtype")
@@ -0,0 +1,58 @@
"""Add ACTIVATED status to PPR, PENDING status to departures, arrival_id FK on departures
Revision ID: 007_ppr_activated_status
Revises: 006_movements
Create Date: 2026-04-03 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '007_ppr_activated_status'
down_revision = '006_movements'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add ACTIVATED to PPR status enum
op.execute(
"ALTER TABLE submitted MODIFY COLUMN status "
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED','ACTIVATED') NOT NULL"
)
# Add PENDING to departures status enum
op.execute(
"ALTER TABLE departures MODIFY COLUMN status "
"ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CANCELLED','PENDING') NOT NULL"
)
# Add arrival_id FK column to departures (nullable - only set for PPR-activated departures)
op.add_column('departures', sa.Column('arrival_id', sa.BigInteger(), nullable=True))
op.create_foreign_key(
'fk_departures_arrival_id', 'departures', 'arrivals',
['arrival_id'], ['id'], ondelete='SET NULL'
)
op.create_index('idx_departures_arrival_id', 'departures', ['arrival_id'])
def downgrade() -> None:
op.drop_index('idx_departures_arrival_id', table_name='departures')
op.drop_constraint('fk_departures_arrival_id', 'departures', type_='foreignkey')
op.drop_column('departures', 'arrival_id')
op.execute(
"UPDATE departures SET status = 'CANCELLED' WHERE status = 'PENDING'"
)
op.execute(
"ALTER TABLE departures MODIFY COLUMN status "
"ENUM('BOOKED_OUT','GROUND','DEPARTED','LOCAL','CANCELLED') NOT NULL"
)
op.execute(
"UPDATE submitted SET status = 'CONFIRMED' WHERE status = 'ACTIVATED'"
)
op.execute(
"ALTER TABLE submitted MODIFY COLUMN status "
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL"
)
+3 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements
api_router = APIRouter()
@@ -11,6 +11,8 @@ 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(movements.router, prefix="/movements", tags=["movements"])
api_router.include_router(public.router, prefix="/public", tags=["public"])
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
+148 -5
View File
@@ -1,9 +1,10 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.deps import get_db, get_current_active_user
from app.models.ppr import Aircraft
from app.schemas.ppr import Aircraft as AircraftSchema
from app.models.ppr import Aircraft, UserAircraft
from app.schemas.ppr import Aircraft as AircraftSchema, UserAircraftCreate, UserAircraft as UserAircraftSchema
from app.models.ppr import User
router = APIRouter()
@@ -18,6 +19,7 @@ async def lookup_aircraft_by_registration(
"""
Lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching.
Checks user_aircraft table first, then aircraft table.
"""
# Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
@@ -25,7 +27,29 @@ async def lookup_aircraft_by_registration(
if len(clean_input) < 4:
return []
# Query aircraft table using clean_reg column
# First check user_aircraft table
user_aircraft = db.query(UserAircraft).filter(
UserAircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
if user_aircraft:
# Convert UserAircraft to Aircraft-like objects
result = []
for ua in user_aircraft:
# Create a mock Aircraft object with the user data
result.append({
'id': ua.id,
'registration': ua.registration,
'type_code': ua.type_code,
'clean_reg': ua.clean_reg,
'icao24': None,
'manufacturer_icao': None,
'manufacturer_name': None,
'model': None
})
return result
# If no user aircraft found, check main aircraft table
aircraft_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
@@ -42,6 +66,7 @@ async def public_lookup_aircraft_by_registration(
Public lookup aircraft by registration (clean match).
Removes non-alphanumeric characters from input for matching.
No authentication required.
Checks user_aircraft table first, then aircraft table.
"""
# Clean the input registration (remove non-alphanumeric characters)
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
@@ -49,7 +74,28 @@ async def public_lookup_aircraft_by_registration(
if len(clean_input) < 4:
return []
# Query aircraft table using clean_reg column
# First check user_aircraft table
user_aircraft = db.query(UserAircraft).filter(
UserAircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
if user_aircraft:
# Convert UserAircraft to Aircraft-like objects
result = []
for ua in user_aircraft:
result.append({
'id': ua.id,
'registration': ua.registration,
'type_code': ua.type_code,
'clean_reg': ua.clean_reg,
'icao24': None,
'manufacturer_icao': None,
'manufacturer_name': None,
'model': None
})
return result
# If no user aircraft found, check main aircraft table
aircraft_list = db.query(Aircraft).filter(
Aircraft.clean_reg.like(f"{clean_input}%")
).limit(10).all()
@@ -81,4 +127,101 @@ async def search_aircraft(
(Aircraft.model.like(f"%{q}%"))
).limit(limit).all()
return aircraft_list
return aircraft_list
@router.post("/user-aircraft", response_model=dict)
async def save_user_aircraft(
aircraft: UserAircraftCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Save a user-defined aircraft type for a registration.
"""
# Clean the registration
clean_reg = ''.join(c for c in aircraft.registration if c.isalnum()).upper()
# Check if already exists
existing = db.query(UserAircraft).filter(
UserAircraft.registration == aircraft.registration.upper()
).first()
if existing:
raise HTTPException(status_code=400, detail="Aircraft registration already exists in user database")
# Create new user aircraft
user_aircraft = UserAircraft(
registration=aircraft.registration.upper(),
type_code=aircraft.type_code.upper(),
clean_reg=clean_reg,
created_by=current_user.username
)
db.add(user_aircraft)
db.commit()
db.refresh(user_aircraft)
return {"message": "Aircraft saved successfully", "id": user_aircraft.id}
@router.get("/user-aircraft", response_model=List[UserAircraftSchema])
async def get_user_aircraft(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get all user-defined aircraft types.
"""
user_aircraft = db.query(UserAircraft).order_by(UserAircraft.created_at.desc()).all()
return user_aircraft
@router.put("/user-aircraft/{registration}", response_model=dict)
async def update_user_aircraft(
registration: str,
aircraft: UserAircraftCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update a user-defined aircraft type.
"""
# Find the existing user aircraft
existing = db.query(UserAircraft).filter(
UserAircraft.registration == registration.upper()
).first()
if not existing:
raise HTTPException(status_code=404, detail="User aircraft not found")
# Update the type
existing.type_code = aircraft.type_code.upper()
existing.updated_at = func.current_timestamp()
db.commit()
return {"message": "Aircraft updated successfully"}
@router.delete("/user-aircraft/{registration}", response_model=dict)
async def delete_user_aircraft(
registration: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a user-defined aircraft type.
"""
# Find the existing user aircraft
existing = db.query(UserAircraft).filter(
UserAircraft.registration == registration.upper()
).first()
if not existing:
raise HTTPException(status_code=404, detail="User aircraft not found")
db.delete(existing)
db.commit()
return {"message": "Aircraft deleted successfully"}
+4 -3
View File
@@ -38,12 +38,12 @@ async def create_arrival(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new arrival record"""
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username)
arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=current_user.username, submitted_via=arrival_in.submitted_via)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in",
"type": "arrival_inbound",
"data": {
"id": arrival.id,
"registration": arrival.registration,
@@ -159,7 +159,8 @@ async def cancel_arrival(
current_user: User = Depends(get_current_operator_user)
):
"""Cancel an arrival record"""
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
client_ip = get_client_ip(request)
arrival = crud_arrival.cancel(db, arrival_id=arrival_id, user=current_user.username, user_ip=client_ip)
if not arrival:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+3 -3
View File
@@ -87,7 +87,7 @@ async def create_user(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
user = crud_user.create(db, obj_in=user_in)
user = crud_user.create(db, obj_in=user_in, admin_user=current_user.username)
return user
@@ -105,7 +105,7 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud_user.update(db, db_obj=user, obj_in=user_in)
user = crud_user.update(db, db_obj=user, obj_in=user_in, admin_user=current_user.username)
return user
@@ -123,5 +123,5 @@ async def change_user_password(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password, admin_user=current_user.username)
return user
+29 -3
View File
@@ -33,6 +33,17 @@ async def get_circuits_by_flight(
return circuits
@router.get("/arrival/{arrival_id}", response_model=List[Circuit])
async def get_circuits_by_arrival(
arrival_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get all circuits for a specific arrival"""
circuits = crud_circuit.get_by_arrival(db, arrival_id=arrival_id)
return circuits
@router.post("/", response_model=Circuit)
async def create_circuit(
request: Request,
@@ -40,8 +51,21 @@ async def create_circuit(
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)
"""Record a new circuit (touch and go) for a local flight or arrival"""
# Validate that exactly one of local_flight_id or arrival_id is provided
if not circuit_in.local_flight_id and not circuit_in.arrival_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either local_flight_id or arrival_id must be provided"
)
if circuit_in.local_flight_id and circuit_in.arrival_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot provide both local_flight_id and arrival_id"
)
client_ip = get_client_ip(request)
circuit = crud_circuit.create(db, obj_in=circuit_in, user=current_user.username, user_ip=client_ip)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
@@ -93,6 +117,7 @@ async def update_circuit(
@router.delete("/{circuit_id}")
async def delete_circuit(
request: Request,
circuit_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
@@ -104,5 +129,6 @@ async def delete_circuit(
status_code=status.HTTP_404_NOT_FOUND,
detail="Circuit record not found"
)
crud_circuit.delete(db, circuit_id=circuit_id)
client_ip = get_client_ip(request)
crud_circuit.delete(db, circuit_id=circuit_id, user=current_user.username, user_ip=client_ip)
return {"detail": "Circuit record deleted"}
+3 -2
View File
@@ -38,7 +38,7 @@ async def create_departure(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new departure record"""
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username)
departure = crud_departure.create(db, obj_in=departure_in, created_by=current_user.username, submitted_via="ADMIN")
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
@@ -159,7 +159,8 @@ async def cancel_departure(
current_user: User = Depends(get_current_operator_user)
):
"""Cancel a departure record"""
departure = crud_departure.cancel(db, departure_id=departure_id)
client_ip = get_client_ip(request)
departure = crud_departure.cancel(db, departure_id=departure_id, user=current_user.username, user_ip=client_ip)
if not departure:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+69 -17
View File
@@ -4,11 +4,79 @@ 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
from typing import List, Optional
from datetime import datetime, date
router = APIRouter(tags=["journal"])
@router.get("/search/all", response_model=List[JournalEntryResponse])
async def search_journal(
date_from: Optional[date] = None,
date_to: Optional[date] = None,
entity_type: Optional[str] = None,
entity_id: Optional[int] = None,
user: Optional[str] = None,
limit: int = 500,
db: Session = Depends(deps.get_db),
current_user = Depends(deps.get_current_user)
):
"""
Search journal entries with optional filters.
Parameters:
- date_from: Filter entries from this date (YYYY-MM-DD)
- date_to: Filter entries until this date (YYYY-MM-DD)
- entity_type: Filter by entity type (PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT, CIRCUIT, USER)
- entity_id: Filter by specific entity ID
- user: Filter by user who created the entry
- limit: Maximum number of entries to return (default 500, max 5000)
All filters are optional and can be combined.
Returns entries in reverse chronological order (newest first).
"""
if limit > 5000:
limit = 5000
# Validate entity_type if provided
if entity_type:
try:
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.search_entries(
db,
date_from=date_from,
date_to=date_to,
entity_type=entity_type,
entity_id=entity_id,
user=user,
limit=limit
)
return 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
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
async def get_entity_journal(
entity_type: str,
@@ -45,19 +113,3 @@ async def get_entity_journal(
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
+3 -2
View File
@@ -39,7 +39,7 @@ async def create_local_flight(
current_user: User = Depends(get_current_operator_user)
):
"""Create a new local flight record (book out)"""
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username)
flight = crud_local_flight.create(db, obj_in=flight_in, created_by=current_user.username, submitted_via="ADMIN")
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
@@ -160,7 +160,8 @@ async def cancel_local_flight(
current_user: User = Depends(get_current_operator_user)
):
"""Cancel a local flight record"""
flight = crud_local_flight.cancel(db, flight_id=flight_id)
client_ip = get_client_ip(request)
flight = crud_local_flight.cancel(db, flight_id=flight_id, user=current_user.username, user_ip=client_ip)
if not flight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+48
View File
@@ -0,0 +1,48 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user
from app.crud.crud_movement import movement as crud_movement
from app.schemas.movement import Movement
from app.models.ppr import User
from app.models.movement import MovementType
router = APIRouter()
@router.get("/", response_model=List[Movement])
async def get_movements(
skip: int = 0,
limit: int = 100,
movement_type: Optional[MovementType] = None,
aircraft_registration: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
entity_type: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get movement records with optional filtering"""
movements = crud_movement.get_multi(
db, skip=skip, limit=limit, movement_type=movement_type,
aircraft_registration=aircraft_registration, date_from=date_from,
date_to=date_to, entity_type=entity_type
)
return movements
@router.get("/{movement_id}", response_model=Movement)
async def get_movement(
movement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific movement record"""
movement = crud_movement.get(db, movement_id=movement_id)
if not movement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Movement record not found"
)
return movement
+79 -1
View File
@@ -5,7 +5,11 @@ from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_ppr import ppr as crud_ppr
from app.crud.crud_journal import journal as crud_journal
from app.crud.crud_arrival import arrival as crud_arrival
from app.crud.crud_departure import departure as crud_departure
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
from app.schemas.arrival import ArrivalCreate
from app.schemas.departure import DepartureCreate
from app.models.ppr import User
from app.core.utils import get_client_ip
from app.core.email import email_service
@@ -373,4 +377,78 @@ async def get_ppr_journal(
detail="PPR record not found"
)
return crud_journal.get_by_ppr_id(db, ppr_id=ppr_id)
return crud_journal.get_by_ppr_id(db, ppr_id=ppr_id)
@router.post("/{ppr_id}/activate")
async def activate_ppr(
request: Request,
ppr_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Activate a PPR by creating BOOKED_IN arrival and (if out_to set) BOOKED_OUT departure records."""
db_ppr = crud_ppr.get(db, ppr_id=ppr_id)
if not db_ppr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="PPR record not found")
if db_ppr.status not in (PPRStatus.NEW, PPRStatus.CONFIRMED):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"PPR cannot be activated in its current state ({db_ppr.status.value})"
)
client_ip = get_client_ip(request)
username = current_user.username
# Create INBOUND arrival (ADMIN submitted_via sets status to INBOUND)
in_from = (db_ppr.in_from or "ZZZZ")[:4].upper()
arrival_in = ArrivalCreate(
registration=db_ppr.ac_reg,
type=db_ppr.ac_type,
callsign=db_ppr.ac_call,
pob=db_ppr.pob_in,
in_from=in_from,
eta=db_ppr.eta,
notes=db_ppr.notes,
submitted_via="ADMIN"
)
new_arrival = crud_arrival.create(db, obj_in=arrival_in, created_by=username, submitted_via="ADMIN", user_ip=client_ip)
# Create PENDING departure linked to this arrival (only visible once arrival lands)
new_departure = None
if db_ppr.out_to:
departure_in = DepartureCreate(
registration=db_ppr.ac_reg,
type=db_ppr.ac_type,
callsign=db_ppr.ac_call,
pob=db_ppr.pob_out if db_ppr.pob_out else db_ppr.pob_in,
out_to=db_ppr.out_to,
etd=db_ppr.etd,
notes=db_ppr.notes,
arrival_id=new_arrival.id,
)
new_departure = crud_departure.create(db, obj_in=departure_in, created_by=username, submitted_via="ADMIN", user_ip=client_ip)
# Mark PPR as ACTIVATED — removes it from Today's PPR and pending arrivals displays
crud_ppr.update_status(db, ppr_id=ppr_id, status=PPRStatus.ACTIVATED, user=username, user_ip=client_ip)
# Broadcast WebSocket update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "ppr_activated",
"data": {
"ppr_id": ppr_id,
"arrival_id": new_arrival.id,
"departure_id": new_departure.id if new_departure else None
}
})
return {
"arrival_id": new_arrival.id,
"departure_id": new_departure.id if new_departure else None,
"message": (
f"PPR activated: arrival #{new_arrival.id} created"
+ (f", departure #{new_departure.id} queued (will appear when aircraft lands)" if new_departure else "")
)
}
+18 -10
View File
@@ -97,11 +97,11 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
# Add booked-in arrivals
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
for arrival in booked_in_arrivals:
# Only include BOOKED_IN and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
# Only include BOOKED_IN, INBOUND and LANDED arrivals
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND, ArrivalStatus.LANDED):
continue
# For BOOKED_IN, only include those created today
if arrival.status == ArrivalStatus.BOOKED_IN:
# For BOOKED_IN and INBOUND, only include those created today
if arrival.status in (ArrivalStatus.BOOKED_IN, ArrivalStatus.INBOUND):
if not (today_start <= arrival.created_dt < today_end):
continue
# For LANDED, only include those landed today
@@ -173,10 +173,10 @@ async def get_public_departures(db: Session = Depends(get_db)):
'isDeparture': False
})
# Add departures to other airports with BOOKED_OUT status
# Add departures to other airports with BOOKED_OUT and GROUND status
departures_to_airports = crud_departure.get_multi(
db,
status=DepartureStatus.BOOKED_OUT,
status=None, # Get all statuses
limit=1000
)
@@ -187,17 +187,25 @@ async def get_public_departures(db: Session = Depends(get_db)):
# Convert departures to match the format for display
for dep in departures_to_airports:
# Only include departures booked out today
if not (today_start <= dep.created_dt < today_end):
# Only include departures booked out today and not yet departed
if not (today_start <= dep.created_dt < today_end) or dep.status == DepartureStatus.DEPARTED:
continue
# Map status for display
display_status = 'BOOKED_OUT'
if dep.status == DepartureStatus.GROUND:
display_status = 'CONTACT'
elif dep.status == DepartureStatus.LOCAL:
display_status = 'DEPARTED'
departures_list.append({
'ac_call': dep.callsign or dep.registration,
'ac_reg': dep.registration,
'ac_type': dep.type,
'out_to': dep.out_to,
'etd': dep.etd or dep.created_dt,
'departed_dt': None,
'status': 'BOOKED_OUT',
'departed_dt': dep.departed_dt,
'status': display_status,
'isLocalFlight': False,
'isDeparture': True
})
+205
View File
@@ -0,0 +1,205 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.config import settings
from app.schemas.public_book import (
PublicLocalFlightCreate,
PublicCircuitCreate,
PublicDepartureCreate,
PublicArrivalCreate,
)
from app.schemas.local_flight import LocalFlight as LocalFlightSchema
from app.schemas.circuit import Circuit as CircuitSchema
from app.schemas.departure import Departure as DepartureSchema
from app.schemas.arrival import Arrival as ArrivalSchema
from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.crud.crud_circuit import crud_circuit
from app.crud.crud_departure import departure as crud_departure
from app.crud.crud_arrival import arrival as crud_arrival
from app.models.local_flight import SubmissionSource
from app.models.departure import DepartureStatus
from app.models.arrival import SubmissionSource as ArrivalSubmissionSource
router = APIRouter()
def check_public_booking_enabled():
"""Check if public booking is enabled"""
if not settings.allow_public_booking:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Public booking is currently disabled"
)
@router.post("/local-flights", response_model=LocalFlightSchema)
async def public_book_local_flight(
request: Request,
flight_in: PublicLocalFlightCreate,
db: Session = Depends(get_db),
):
"""Book a local flight via public portal"""
check_public_booking_enabled()
# Create the flight with public submission source
from app.schemas.local_flight import LocalFlightCreate
flight_create = LocalFlightCreate(
registration=flight_in.registration,
type=flight_in.type,
callsign=flight_in.callsign,
pob=flight_in.pob,
flight_type=flight_in.flight_type,
duration=flight_in.duration,
etd=flight_in.etd,
notes=flight_in.notes,
)
flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
# Update with submission source and pilot email
db.query(type(flight)).filter(type(flight).id == flight.id).update({
type(flight).submitted_via: SubmissionSource.PUBLIC,
type(flight).pilot_email: flight_in.pilot_email,
})
db.commit()
db.refresh(flight)
# Send real-time update via WebSocket if available
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "local_flight_booked_out",
"data": {
"id": flight.id,
"registration": flight.registration,
"flight_type": flight.flight_type.value,
"status": flight.status.value,
"submitted_via": "PUBLIC"
}
})
return flight
@router.post("/circuits", response_model=CircuitSchema)
async def public_record_circuit(
request: Request,
circuit_in: PublicCircuitCreate,
db: Session = Depends(get_db),
):
"""Record a circuit (touch and go) via public portal"""
check_public_booking_enabled()
from app.schemas.circuit import CircuitCreate
circuit_create = CircuitCreate(
local_flight_id=circuit_in.local_flight_id,
circuit_timestamp=circuit_in.circuit_timestamp,
)
circuit = crud_circuit.create(db, obj_in=circuit_create, user="PUBLIC_PILOT", user_ip=request.client.host if request.client else None)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "circuit_recorded",
"data": {
"id": circuit.id,
"local_flight_id": circuit.local_flight_id,
"circuit_timestamp": circuit.circuit_timestamp.isoformat(),
"submitted_via": "PUBLIC"
}
})
return circuit
@router.post("/departures", response_model=DepartureSchema)
async def public_book_departure(
request: Request,
departure_in: PublicDepartureCreate,
db: Session = Depends(get_db),
):
"""Book a departure via public portal"""
check_public_booking_enabled()
from app.schemas.departure import DepartureCreate
departure_create = DepartureCreate(
registration=departure_in.registration,
type=departure_in.type,
callsign=departure_in.callsign,
pob=departure_in.pob,
out_to=departure_in.out_to,
etd=departure_in.etd,
notes=departure_in.notes,
)
departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
# Update with pilot email (submitted_via is already set in create method)
db.query(type(departure)).filter(type(departure).id == departure.id).update({
type(departure).pilot_email: departure_in.pilot_email,
})
db.commit()
db.refresh(departure)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "departure_booked_out",
"data": {
"id": departure.id,
"registration": departure.registration,
"status": departure.status.value,
"submitted_via": "PUBLIC"
}
})
return departure
@router.post("/arrivals", response_model=ArrivalSchema)
async def public_book_arrival(
request: Request,
arrival_in: PublicArrivalCreate,
db: Session = Depends(get_db),
):
"""Book an arrival via public portal"""
check_public_booking_enabled()
from app.schemas.arrival import ArrivalCreate
arrival_create = ArrivalCreate(
registration=arrival_in.registration,
type=arrival_in.type,
callsign=arrival_in.callsign,
pob=arrival_in.pob,
in_from=arrival_in.in_from,
eta=arrival_in.eta,
notes=arrival_in.notes,
)
arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
# Update with pilot email
db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({
type(arrival).pilot_email: arrival_in.pilot_email,
})
db.commit()
db.refresh(arrival)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "arrival_booked_in",
"data": {
"id": arrival.id,
"registration": arrival.registration,
"status": arrival.status.value,
"submitted_via": "PUBLIC"
}
})
return arrival
+3
View File
@@ -33,6 +33,9 @@ class Settings(BaseSettings):
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Public booking settings
allow_public_booking: bool = False # Enable/disable public flight booking
# Redis settings (for future use)
redis_url: Optional[str] = None
+93 -8
View File
@@ -6,6 +6,9 @@ from app.models.arrival import Arrival, ArrivalStatus
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
from app.crud.crud_movement import movement as movement_crud
from app.schemas.movement import MovementCreate
from app.models.movement import MovementType
class CRUDArrival:
@@ -24,7 +27,17 @@ class CRUDArrival:
query = db.query(Arrival)
if status:
query = query.filter(Arrival.status == status)
if status == ArrivalStatus.CIRCUIT:
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
circuit_statuses = [
ArrivalStatus.CIRCUIT,
ArrivalStatus.CIRCUIT_DOWNWIND,
ArrivalStatus.CIRCUIT_BASE,
ArrivalStatus.CIRCUIT_FINAL
]
query = query.filter(Arrival.status.in_(circuit_statuses))
else:
query = query.filter(Arrival.status == status)
if date_from:
query = query.filter(func.date(Arrival.created_dt) >= date_from)
@@ -35,27 +48,48 @@ class CRUDArrival:
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
def get_arrivals_today(self, db: Session) -> List[Arrival]:
"""Get today's arrivals (booked in or landed)"""
"""Get today's arrivals (booked in, inbound or landed)"""
today = date.today()
return db.query(Arrival).filter(
and_(
func.date(Arrival.created_dt) == today,
or_(
Arrival.status == ArrivalStatus.BOOKED_IN,
Arrival.status == ArrivalStatus.INBOUND,
Arrival.status == ArrivalStatus.LANDED
)
)
).order_by(Arrival.created_dt).all()
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Arrival:
from app.models.arrival import SubmissionSource
# Set initial status based on submission source
initial_status = ArrivalStatus.BOOKED_IN
if submitted_via == SubmissionSource.ADMIN:
initial_status = ArrivalStatus.INBOUND
db_obj = Arrival(
**obj_in.dict(),
**obj_in.dict(exclude={'submitted_via'}),
created_by=created_by,
status=ArrivalStatus.BOOKED_IN
status=initial_status,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log creation in journal
journal.log_change(
db,
EntityType.ARRIVAL,
db_obj.id,
f"Arrival created: {db_obj.registration}",
created_by,
user_ip
)
return db_obj
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
@@ -113,13 +147,52 @@ class CRUDArrival:
old_status = db_obj.status
db_obj.status = status
if status == ArrivalStatus.LANDED and timestamp:
db_obj.landed_dt = timestamp
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == ArrivalStatus.LANDED:
db_obj.landed_dt = current_time
elif status == ArrivalStatus.ARRIVED:
db_obj.arrived_dt = current_time
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Create movement record if applicable
if status == ArrivalStatus.LANDED and db_obj.landed_dt:
movement_data = MovementCreate(
movement_type=MovementType.LANDING,
aircraft_registration=db_obj.registration,
aircraft_type=db_obj.type,
callsign=db_obj.callsign,
timestamp=db_obj.landed_dt,
entity_type="ARRIVAL",
entity_id=arrival_id,
from_location=db_obj.in_from,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
# Promote any PENDING departure linked to this arrival to BOOKED_OUT
from app.models.departure import Departure as DepartureModel, DepartureStatus as DepStatus
pending_dep = db.query(DepartureModel).filter(
DepartureModel.arrival_id == arrival_id,
DepartureModel.status == DepStatus.PENDING
).first()
if pending_dep:
pending_dep.status = DepStatus.BOOKED_OUT
db.add(pending_dep)
db.commit()
journal.log_change(
db,
EntityType.ARRIVAL,
arrival_id,
f"Linked departure #{pending_dep.id} promoted to BOOKED_OUT on landing",
user,
user_ip
)
# Log status change in journal
journal.log_change(
db,
@@ -132,15 +205,27 @@ class CRUDArrival:
return db_obj
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
def cancel(self, db: Session, arrival_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Arrival]:
db_obj = self.get(db, arrival_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = ArrivalStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log cancellation in journal
journal.log_change(
db,
EntityType.ARRIVAL,
arrival_id,
f"Status changed from {old_status.value} to CANCELLED",
user,
user_ip
)
return db_obj
+103 -4
View File
@@ -4,6 +4,13 @@ from sqlalchemy import desc
from datetime import datetime
from app.models.circuit import Circuit
from app.schemas.circuit import CircuitCreate, CircuitUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
from app.crud.crud_movement import movement as movement_crud
from app.schemas.movement import MovementCreate
from app.models.movement import MovementType
from app.crud.crud_local_flight import local_flight as crud_local_flight
from app.crud.crud_arrival import arrival as crud_arrival
class CRUDCircuit:
@@ -16,6 +23,12 @@ class CRUDCircuit:
Circuit.local_flight_id == local_flight_id
).order_by(Circuit.circuit_timestamp).all()
def get_by_arrival(self, db: Session, arrival_id: int) -> List[Circuit]:
"""Get all circuits for a specific arrival"""
return db.query(Circuit).filter(
Circuit.arrival_id == arrival_id
).order_by(Circuit.circuit_timestamp).all()
def get_multi(
self,
db: Session,
@@ -24,30 +37,116 @@ class CRUDCircuit:
) -> 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:
def create(self, db: Session, obj_in: CircuitCreate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
db_obj = Circuit(
local_flight_id=obj_in.local_flight_id,
arrival_id=obj_in.arrival_id,
circuit_timestamp=obj_in.circuit_timestamp
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log circuit creation in journal
# Use LOCAL_FLIGHT entity type if local_flight_id exists, otherwise ARRIVAL
entity_type = EntityType.LOCAL_FLIGHT if obj_in.local_flight_id else EntityType.ARRIVAL
entity_id = obj_in.local_flight_id if obj_in.local_flight_id else obj_in.arrival_id
journal.log_change(
db,
entity_type,
entity_id,
f"Circuit recorded at {obj_in.circuit_timestamp.isoformat()}",
user,
user_ip
)
# Create TOUCH_AND_GO movement
if obj_in.local_flight_id:
flight = crud_local_flight.get(db, obj_in.local_flight_id)
if flight:
movement_data = MovementCreate(
movement_type=MovementType.TOUCH_AND_GO,
aircraft_registration=flight.registration,
aircraft_type=flight.type,
callsign=flight.callsign,
timestamp=obj_in.circuit_timestamp,
entity_type="LOCAL_FLIGHT",
entity_id=obj_in.local_flight_id,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
elif obj_in.arrival_id:
arrival = crud_arrival.get(db, obj_in.arrival_id)
if arrival:
movement_data = MovementCreate(
movement_type=MovementType.TOUCH_AND_GO,
aircraft_registration=arrival.registration,
aircraft_type=arrival.type,
callsign=arrival.callsign,
timestamp=obj_in.circuit_timestamp,
entity_type="ARRIVAL",
entity_id=obj_in.arrival_id,
from_location=arrival.in_from,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
return db_obj
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
obj_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in obj_data.items():
setattr(db_obj, field, value)
old_value = getattr(db_obj, field)
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal if any were made
if changes:
entity_type = EntityType.LOCAL_FLIGHT if db_obj.local_flight_id else EntityType.ARRIVAL
entity_id = db_obj.local_flight_id if db_obj.local_flight_id else db_obj.arrival_id
for change in changes:
journal.log_change(
db,
entity_type,
entity_id,
f"Circuit: {change}",
user,
user_ip
)
return db_obj
def delete(self, db: Session, circuit_id: int) -> bool:
def delete(self, db: Session, circuit_id: int, user: str = "system", user_ip: Optional[str] = None) -> bool:
circuit = self.get(db, circuit_id)
if circuit:
# Determine which entity this circuit belongs to
entity_type = EntityType.LOCAL_FLIGHT if circuit.local_flight_id else EntityType.ARRIVAL
entity_id = circuit.local_flight_id if circuit.local_flight_id else circuit.arrival_id
db.delete(circuit)
db.commit()
# Log deletion in journal
journal.log_change(
db,
entity_type,
entity_id,
f"Circuit deleted (recorded at {circuit.circuit_timestamp.isoformat()})",
user,
user_ip
)
return True
return False
+75 -6
View File
@@ -6,6 +6,9 @@ from app.models.departure import Departure, DepartureStatus
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
from app.crud.crud_movement import movement as movement_crud
from app.schemas.movement import MovementCreate
from app.models.movement import MovementType
class CRUDDeparture:
@@ -47,15 +50,47 @@ class CRUDDeparture:
)
).order_by(Departure.created_dt).all()
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Departure:
from app.models.departure import SubmissionSource
# Set initial status based on submission source
initial_status = DepartureStatus.BOOKED_OUT
contact_dt = None
if submitted_via == SubmissionSource.ADMIN:
initial_status = DepartureStatus.GROUND
contact_dt = func.now() # Set contact_dt to creation time for admin submissions
obj_data = obj_in.dict()
arrival_id = obj_data.pop('arrival_id', None)
# If arrival_id is provided this is a PPR-linked departure — stay PENDING until arrival lands
if arrival_id is not None:
initial_status = DepartureStatus.PENDING
contact_dt = None
db_obj = Departure(
**obj_in.dict(),
**obj_data,
arrival_id=arrival_id,
created_by=created_by,
status=DepartureStatus.BOOKED_OUT
status=initial_status,
contact_dt=contact_dt,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log creation in journal
journal.log_change(
db,
EntityType.DEPARTURE,
db_obj.id,
f"Departure created: {db_obj.registration}",
created_by,
user_ip
)
return db_obj
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
@@ -113,13 +148,35 @@ class CRUDDeparture:
old_status = db_obj.status
db_obj.status = status
if status == DepartureStatus.DEPARTED and timestamp:
db_obj.departed_dt = timestamp
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == DepartureStatus.GROUND:
db_obj.contact_dt = current_time
elif status == DepartureStatus.DEPARTED:
db_obj.departed_dt = current_time
elif status == DepartureStatus.LOCAL:
db_obj.takeoff_dt = current_time
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Create movement record if applicable
if db_obj.takeoff_dt and status == DepartureStatus.LOCAL:
movement_data = MovementCreate(
movement_type=MovementType.TAKEOFF,
aircraft_registration=db_obj.registration,
aircraft_type=db_obj.type,
callsign=db_obj.callsign,
timestamp=db_obj.takeoff_dt,
entity_type="DEPARTURE",
entity_id=departure_id,
to_location=db_obj.out_to,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
# Log status change in journal
journal.log_change(
db,
@@ -132,15 +189,27 @@ class CRUDDeparture:
return db_obj
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
def cancel(self, db: Session, departure_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Departure]:
db_obj = self.get(db, departure_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = DepartureStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log cancellation in journal
journal.log_change(
db,
EntityType.DEPARTURE,
departure_id,
f"Status changed from {old_status.value} to CANCELLED",
user,
user_ip
)
return db_obj
+37 -1
View File
@@ -1,7 +1,8 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from app.models.journal import JournalEntry, EntityType
from datetime import datetime
from datetime import datetime, date
class CRUDJournal:
@@ -58,6 +59,41 @@ class CRUDJournal:
JournalEntry.user == user
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
def search_entries(
self,
db: Session,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
entity_type: Optional[str] = None,
entity_id: Optional[int] = None,
user: Optional[str] = None,
limit: int = 500
) -> List[JournalEntry]:
"""Search journal entries with optional filters."""
query = db.query(JournalEntry)
# Apply date filters
if date_from:
query = query.filter(func.date(JournalEntry.entry_dt) >= date_from)
if date_to:
query = query.filter(func.date(JournalEntry.entry_dt) <= date_to)
# Apply entity type filter
if entity_type:
query = query.filter(JournalEntry.entity_type == entity_type.upper())
# Apply entity ID filter
if entity_id:
query = query.filter(JournalEntry.entity_id == entity_id)
# Apply user filter
if user:
query = query.filter(JournalEntry.user == user)
# Order by date descending (newest first) and apply limit
return query.order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
# Convenience methods for backward compatibility with PPR journal
def log_ppr_change(
self,
+90 -6
View File
@@ -7,6 +7,9 @@ from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, Local
from app.models.journal import EntityType
from app.models.circuit import Circuit
from app.crud.crud_journal import journal
from app.crud.crud_movement import movement as movement_crud
from app.schemas.movement import MovementCreate
from app.models.movement import MovementType
class CRUDLocalFlight:
@@ -26,7 +29,17 @@ class CRUDLocalFlight:
query = db.query(LocalFlight)
if status:
query = query.filter(LocalFlight.status == status)
if status == LocalFlightStatus.CIRCUIT:
# Special case: when requesting CIRCUIT status, return all circuit-related statuses
circuit_statuses = [
LocalFlightStatus.CIRCUIT,
LocalFlightStatus.CIRCUIT_DOWNWIND,
LocalFlightStatus.CIRCUIT_BASE,
LocalFlightStatus.CIRCUIT_FINAL
]
query = query.filter(LocalFlight.status.in_(circuit_statuses))
else:
query = query.filter(LocalFlight.status == status)
if flight_type:
query = query.filter(LocalFlight.flight_type == flight_type)
@@ -74,15 +87,35 @@ class CRUDLocalFlight:
)
).order_by(LocalFlight.created_dt).all()
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> LocalFlight:
from app.models.local_flight import SubmissionSource
# Set initial status based on submission source
initial_status = LocalFlightStatus.BOOKED_OUT
if submitted_via == SubmissionSource.ADMIN:
initial_status = LocalFlightStatus.GROUND
db_obj = LocalFlight(
**obj_in.dict(),
created_by=created_by,
status=LocalFlightStatus.BOOKED_OUT
status=initial_status,
submitted_via=submitted_via
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log creation in journal
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
db_obj.id,
f"Local flight created: {db_obj.registration} ({db_obj.flight_type.value})",
created_by,
user_ip
)
return db_obj
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
@@ -144,11 +177,19 @@ class CRUDLocalFlight:
old_status = db_obj.status
db_obj.status = status
# Update flight_type based on status changes
if status == LocalFlightStatus.LOCAL:
db_obj.flight_type = LocalFlightType.LOCAL
elif status == LocalFlightStatus.CIRCUIT:
db_obj.flight_type = LocalFlightType.CIRCUITS
# Set timestamps based on status
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == LocalFlightStatus.DEPARTED:
if status == LocalFlightStatus.GROUND:
db_obj.contact_dt = current_time
elif status == LocalFlightStatus.DEPARTED:
db_obj.departed_dt = current_time
elif status == LocalFlightStatus.LANDED:
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
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(
@@ -156,10 +197,42 @@ class CRUDLocalFlight:
).scalar()
db_obj.circuits = circuit_count
# Takeoff: happens once when transitioning away from GROUND
if old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT) and not db_obj.takeoff_dt:
db_obj.takeoff_dt = current_time
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Create movement record if applicable
if db_obj.takeoff_dt and old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT):
movement_data = MovementCreate(
movement_type=MovementType.TAKEOFF,
aircraft_registration=db_obj.registration,
aircraft_type=db_obj.type,
callsign=db_obj.callsign,
timestamp=db_obj.takeoff_dt,
entity_type="LOCAL_FLIGHT",
entity_id=flight_id,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
if db_obj.landed_dt and status == LocalFlightStatus.LANDED:
movement_data = MovementCreate(
movement_type=MovementType.LANDING,
aircraft_registration=db_obj.registration,
aircraft_type=db_obj.type,
callsign=db_obj.callsign,
timestamp=db_obj.landed_dt,
entity_type="LOCAL_FLIGHT",
entity_id=flight_id,
created_by=user,
ip_address=user_ip
)
movement_crud.create(db, movement_data)
# Log status change in journal
journal.log_change(
db,
@@ -172,13 +245,24 @@ class CRUDLocalFlight:
return db_obj
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
def cancel(self, db: Session, flight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[LocalFlight]:
db_obj = self.get(db, flight_id)
if db_obj:
old_status = db_obj.status
db_obj.status = LocalFlightStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log cancellation in journal
journal.log_change(
db,
EntityType.LOCAL_FLIGHT,
flight_id,
f"Status changed from {old_status.value} to CANCELLED",
user,
user_ip
)
return db_obj
+61
View File
@@ -0,0 +1,61 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from datetime import date, datetime
from app.models.movement import Movement, MovementType
from app.schemas.movement import MovementCreate
class CRUDMovement:
def get(self, db: Session, movement_id: int) -> Optional[Movement]:
return db.query(Movement).filter(Movement.id == movement_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
movement_type: Optional[MovementType] = None,
aircraft_registration: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
entity_type: Optional[str] = None
) -> List[Movement]:
query = db.query(Movement)
if movement_type:
query = query.filter(Movement.movement_type == movement_type)
if aircraft_registration:
query = query.filter(Movement.aircraft_registration.ilike(f"%{aircraft_registration}%"))
if date_from:
query = query.filter(func.date(Movement.timestamp) >= date_from)
if date_to:
query = query.filter(func.date(Movement.timestamp) <= date_to)
if entity_type:
query = query.filter(Movement.entity_type == entity_type)
return query.order_by(Movement.timestamp.desc()).offset(skip).limit(limit).all()
def create(self, db: Session, obj_in: MovementCreate) -> Movement:
db_obj = Movement(**obj_in.dict())
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]:
return db.query(Movement).filter(
and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id)
).order_by(Movement.timestamp).all()
def get_daily_movements(self, db: Session, target_date: date) -> List[Movement]:
return db.query(Movement).filter(
func.date(Movement.timestamp) == target_date
).order_by(Movement.timestamp).all()
movement = CRUDMovement()
+18
View File
@@ -6,6 +6,9 @@ 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
from app.crud.crud_movement import movement as movement_crud
from app.schemas.movement import MovementCreate
from app.models.movement import MovementType
class CRUDOverflight:
@@ -57,6 +60,21 @@ class CRUDOverflight:
db.commit()
db.refresh(db_obj)
# Create OVERFLIGHT movement if call_dt is set
if db_obj.call_dt:
movement_data = MovementCreate(
movement_type=MovementType.OVERFLIGHT,
aircraft_registration=db_obj.registration,
aircraft_type=db_obj.type,
timestamp=db_obj.call_dt,
entity_type="OVERFLIGHT",
entity_id=db_obj.id,
from_location=db_obj.departure_airfield,
to_location=db_obj.destination_airfield,
created_by=created_by
)
movement_crud.create(db, movement_data)
# Log creation in journal
journal.log_change(
db,
+46 -4
View File
@@ -3,6 +3,8 @@ from sqlalchemy.orm import Session
from app.models.ppr import User
from app.schemas.ppr import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDUser:
@@ -15,7 +17,7 @@ class CRUDUser:
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
return db.query(User).offset(skip).limit(limit).all()
def create(self, db: Session, obj_in: UserCreate) -> User:
def create(self, db: Session, obj_in: UserCreate, admin_user: str = "system") -> User:
hashed_password = get_password_hash(obj_in.password)
db_obj = User(
username=obj_in.username,
@@ -25,17 +27,46 @@ class CRUDUser:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log user creation in journal
journal.log_change(
db,
EntityType.USER,
db_obj.id,
f"User created: {obj_in.username} with role {obj_in.role}",
admin_user,
None
)
return db_obj
def update(self, db: Session, db_obj: User, obj_in: UserUpdate) -> User:
def update(self, db: Session, db_obj: User, obj_in: UserUpdate, admin_user: str = "system") -> User:
update_data = obj_in.dict(exclude_unset=True)
changes = []
if "password" in update_data:
update_data["password"] = get_password_hash(update_data["password"])
changes.append("password changed")
for field, value in update_data.items():
setattr(db_obj, field, value)
old_value = getattr(db_obj, field)
if field == "password" or old_value != value:
if field != "password": # Don't log actual password values
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log user update in journal
if changes:
journal.log_change(
db,
EntityType.USER,
db_obj.id,
"; ".join(changes),
admin_user,
None
)
return db_obj
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]:
@@ -50,13 +81,24 @@ class CRUDUser:
# For future use if we add user status
return True
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
def change_password(self, db: Session, db_obj: User, new_password: str, admin_user: str = "system") -> User:
"""Change a user's password (typically used by admins to reset another user's password)"""
hashed_password = get_password_hash(new_password)
db_obj.password = hashed_password
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log password change in journal (security audit)
journal.log_change(
db,
EntityType.USER,
db_obj.id,
f"Password changed by {admin_user}",
admin_user,
None
)
return db_obj
+2
View File
@@ -14,6 +14,8 @@ from app.models.journal import JournalEntry
from app.models.local_flight import LocalFlight
from app.models.departure import Departure
from app.models.arrival import Arrival
from app.models.circuit import Circuit
from app.models.movement import Movement
# Set up logging
logging.basicConfig(level=logging.INFO)
+17 -3
View File
@@ -1,14 +1,25 @@
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
from sqlalchemy.ext.declarative import declarative_base
from enum import Enum
from datetime import datetime
from app.db.session import Base
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED"
@@ -21,10 +32,13 @@ class Arrival(Base):
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False)
in_from = Column(String(4), nullable=False, index=True)
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True)
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.INBOUND, nullable=False, index=True)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
eta = Column(DateTime, nullable=True, index=True)
landed_dt = Column(DateTime, nullable=True)
arrived_dt = Column(DateTime, nullable=True) # Time when aircraft parks and shuts down
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+2 -1
View File
@@ -7,6 +7,7 @@ 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)
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=True, index=True)
arrival_id = Column(BigInteger, ForeignKey("arrivals.id", ondelete="CASCADE"), nullable=True, index=True)
circuit_timestamp = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
+14 -1
View File
@@ -6,10 +6,18 @@ from datetime import datetime
Base = declarative_base()
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED"
PENDING = "PENDING"
class Departure(Base):
@@ -25,6 +33,11 @@ class Departure(Base):
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
departed_dt = Column(DateTime, nullable=True) # Actual departure time
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
departed_dt = Column(DateTime, nullable=True) # Actual departure time (QSY)
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
arrival_id = Column(BigInteger, nullable=True) # Linked arrival for PPR-activated departures
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+2
View File
@@ -11,6 +11,8 @@ class EntityType(str, PyEnum):
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
OVERFLIGHT = "OVERFLIGHT"
CIRCUIT = "CIRCUIT"
USER = "USER"
class JournalEntry(Base):
+15
View File
@@ -4,6 +4,11 @@ from enum import Enum
from app.db.session import Base
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
@@ -12,7 +17,13 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
@@ -32,7 +43,11 @@ class LocalFlight(Base):
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
contact_dt = Column(DateTime, nullable=True) # Time when contact is established with pilot
departed_dt = Column(DateTime, nullable=True) # Actual takeoff time
takeoff_dt = Column(DateTime, nullable=True) # Time when aircraft becomes airborne
landed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
submitted_via = Column(SQLEnum(SubmissionSource), nullable=False, default=SubmissionSource.ADMIN, index=True)
pilot_email = Column(String(128), nullable=True) # For public submissions
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+37
View File
@@ -0,0 +1,37 @@
from sqlalchemy import Column, BigInteger, String, DateTime, Enum as SQLEnum, func, Index
from enum import Enum
from app.db.session import Base
class MovementType(str, Enum):
TAKEOFF = "TAKEOFF" # Aircraft becomes airborne
LANDING = "LANDING" # Aircraft touches down
OVERFLIGHT = "OVERFLIGHT" # Aircraft passes through airspace (e.g., on call or QSY)
GO_AROUND = "GO_AROUND" # Aircraft aborts landing and goes around
TOUCH_AND_GO = "TOUCH_AND_GO" # Aircraft lands and immediately takes off again
class Movement(Base):
__tablename__ = "movements"
id = Column(BigInteger, primary_key=True, autoincrement=True)
movement_type = Column(SQLEnum(MovementType), nullable=False, index=True)
aircraft_registration = Column(String(16), nullable=False, index=True)
aircraft_type = Column(String(32), nullable=True)
callsign = Column(String(16), nullable=True)
timestamp = Column(DateTime, nullable=False, index=True) # Exact time of movement
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the associated flight record
to_location = Column(String(64), nullable=True) # Destination (TO) - populated based on movement type
from_location = Column(String(64), nullable=True) # Origin (FROM) - populated based on movement type
runway = Column(String(10), nullable=True) # Runway used (e.g., "10", "28", "04", "22")
wind = Column(String(20), nullable=True) # Wind speed/direction (e.g., "280/25")
pressure_setting = Column(String(20), nullable=True) # Pressure setting (e.g., "QNH1024", "QFE1013")
created_by = Column(String(16), nullable=True, index=True) # User who triggered the movement
ip_address = Column(String(45), nullable=True) # For audit
notes = Column(String(255), nullable=True) # Optional context (e.g., runway used)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
# Composite index for efficient queries
__table_args__ = (
Index('idx_movement_lookup', 'entity_type', 'entity_id'),
Index('idx_movement_time', 'timestamp', 'movement_type'),
)
+13
View File
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
LANDED = "LANDED"
DELETED = "DELETED"
DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED"
class UserRole(str, Enum):
@@ -88,4 +89,16 @@ class Aircraft(Base):
model = Column(String(255), nullable=True)
clean_reg = Column(String(25), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
class UserAircraft(Base):
__tablename__ = "user_aircraft"
id = Column(Integer, primary_key=True, autoincrement=True)
registration = Column(String(25), nullable=False, unique=True, index=True)
type_code = Column(String(30), nullable=False)
clean_reg = Column(String(25), nullable=False, index=True)
created_by = Column(String(16), nullable=False, index=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+21
View File
@@ -6,10 +6,23 @@ from enum import Enum
class ArrivalStatus(str, Enum):
BOOKED_IN = "BOOKED_IN"
INBOUND = "INBOUND"
LANDED = "LANDED"
GROUND = "GROUND"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
ARRIVED = "ARRIVED"
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class ArrivalBase(BaseModel):
registration: str
type: Optional[str] = None
@@ -39,6 +52,7 @@ class ArrivalBase(BaseModel):
class ArrivalCreate(ArrivalBase):
eta: Optional[datetime] = None
submitted_via: Optional[SubmissionSource] = SubmissionSource.ADMIN
class ArrivalUpdate(BaseModel):
@@ -47,6 +61,10 @@ class ArrivalUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
in_from: Optional[str] = None
status: Optional[ArrivalStatus] = None
eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None
arrived_dt: Optional[datetime] = None
notes: Optional[str] = None
@@ -61,8 +79,11 @@ class Arrival(ArrivalBase):
created_dt: datetime
eta: Optional[datetime] = None
landed_dt: Optional[datetime] = None
arrived_dt: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True
+2 -1
View File
@@ -4,7 +4,8 @@ from typing import Optional
class CircuitBase(BaseModel):
local_flight_id: int
local_flight_id: Optional[int] = None
arrival_id: Optional[int] = None
circuit_timestamp: datetime
+22 -1
View File
@@ -6,8 +6,16 @@ from enum import Enum
class DepartureStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CANCELLED = "CANCELLED"
PENDING = "PENDING"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class DepartureBase(BaseModel):
@@ -39,7 +47,7 @@ class DepartureBase(BaseModel):
class DepartureCreate(DepartureBase):
pass
arrival_id: Optional[int] = None
class DepartureUpdate(BaseModel):
@@ -48,7 +56,11 @@ class DepartureUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
out_to: Optional[str] = None
status: Optional[DepartureStatus] = None
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
notes: Optional[str] = None
@@ -62,4 +74,13 @@ class Departure(DepartureBase):
status: DepartureStatus
created_dt: datetime
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
arrival_id: Optional[int] = None
class Config:
from_attributes = True
+18
View File
@@ -12,11 +12,22 @@ class LocalFlightType(str, Enum):
class LocalFlightStatus(str, Enum):
BOOKED_OUT = "BOOKED_OUT"
GROUND = "GROUND"
DEPARTED = "DEPARTED"
LOCAL = "LOCAL"
CIRCUIT = "CIRCUIT"
CIRCUIT_DOWNWIND = "CIRCUIT_DOWNWIND"
CIRCUIT_BASE = "CIRCUIT_BASE"
CIRCUIT_FINAL = "CIRCUIT_FINAL"
LANDED = "LANDED"
CANCELLED = "CANCELLED"
class SubmissionSource(str, Enum):
ADMIN = "ADMIN"
PUBLIC = "PUBLIC"
class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
@@ -61,7 +72,10 @@ class LocalFlightUpdate(BaseModel):
duration: Optional[int] = None
status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
notes: Optional[str] = None
@@ -76,11 +90,15 @@ class LocalFlightInDBBase(LocalFlightBase):
status: LocalFlightStatus
created_dt: datetime
etd: Optional[datetime] = None
contact_dt: Optional[datetime] = None
departed_dt: Optional[datetime] = None
takeoff_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
created_by: Optional[str] = None
updated_at: datetime
submitted_via: Optional[SubmissionSource] = None
pilot_email: Optional[str] = None
class Config:
from_attributes = True
+34
View File
@@ -0,0 +1,34 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from app.models.movement import MovementType
class MovementBase(BaseModel):
movement_type: MovementType
aircraft_registration: str
aircraft_type: Optional[str] = None
callsign: Optional[str] = None
timestamp: datetime
entity_type: str
entity_id: int
to_location: Optional[str] = None
from_location: Optional[str] = None
runway: Optional[str] = None
wind: Optional[str] = None
pressure_setting: Optional[str] = None
created_by: Optional[str] = None
ip_address: Optional[str] = None
notes: Optional[str] = None
class MovementCreate(MovementBase):
pass
class Movement(MovementBase):
id: int
created_at: datetime
class Config:
from_attributes = True
+22 -1
View File
@@ -11,6 +11,7 @@ class PPRStatus(str, Enum):
LANDED = "LANDED"
DELETED = "DELETED"
DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED"
class UserRole(str, Enum):
@@ -214,4 +215,24 @@ class Aircraft(AircraftBase):
id: int
class Config:
from_attributes = True
from_attributes = True
# User Aircraft schemas
class UserAircraftBase(BaseModel):
registration: str
type_code: str
clean_reg: str
created_by: str
class UserAircraft(UserAircraftBase):
id: int
class Config:
from_attributes = True
class UserAircraftCreate(BaseModel):
registration: str
type_code: str
+129
View File
@@ -0,0 +1,129 @@
from pydantic import BaseModel, validator, EmailStr
from datetime import datetime
from typing import Optional
from enum import Enum
class LocalFlightType(str, Enum):
LOCAL = "LOCAL"
CIRCUITS = "CIRCUITS"
DEPARTURE = "DEPARTURE"
class PublicLocalFlightCreate(BaseModel):
"""Schema for public local flight booking"""
registration: str
type: Optional[str] = None # Aircraft type - optional
callsign: Optional[str] = None
pob: int
flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
pilot_email: Optional[str] = None # Pilot's email for contact (optional)
pilot_name: Optional[str] = None # Pilot's name
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicCircuitCreate(BaseModel):
"""Schema for public circuit (touch and go) recording"""
local_flight_id: int
circuit_timestamp: datetime
pilot_email: Optional[str] = None
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicDepartureCreate(BaseModel):
"""Schema for public departure booking"""
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
out_to: str
etd: Optional[datetime] = None # Estimated Time of Departure
notes: Optional[str] = None
pilot_email: Optional[str] = None
pilot_name: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('out_to')
def validate_out_to(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Destination airport is required')
return v.strip()
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
class PublicArrivalCreate(BaseModel):
"""Schema for public arrival booking"""
registration: str
type: Optional[str] = None
callsign: Optional[str] = None
pob: int
in_from: str
eta: Optional[datetime] = None
notes: Optional[str] = None
pilot_email: Optional[str] = None
pilot_name: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('in_from')
def validate_in_from(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Origin airport is required')
return v.strip()
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
@validator('pilot_email', pre=True, always=False)
def validate_pilot_email(cls, v):
if v is None or v == '':
return None
return v.strip().lower()
+6
View File
@@ -174,6 +174,12 @@ else
exit 1
fi
echo ""
echo "========================================="
echo "Generating QR Code"
echo "========================================="
python3 /app/generate_qr.py
echo ""
echo "========================================="
echo "Starting Application Server"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Generate booking QR code at container startup"""
import os
import sys
import subprocess
def generate_booking_qr():
"""Generate QR code for the booking page"""
# Get base URL from environment, default to localhost
base_url = os.environ.get('BASE_URL', 'http://localhost')
booking_url = f"{base_url}/book"
# Create output directory if it doesn't exist
output_dir = '/web/assets'
os.makedirs(output_dir, exist_ok=True)
output_file = f'{output_dir}/booking-qr.png'
try:
# Generate QR code using qrencode
subprocess.run(
['qrencode', '-o', output_file, '-s', '5', booking_url],
check=True,
capture_output=True
)
print(f"✓ Generated booking QR code: {output_file}")
print(f" URL: {booking_url}")
return True
except subprocess.CalledProcessError as e:
print(f"✗ Failed to generate QR code: {e.stderr.decode()}", file=sys.stderr)
return False
except FileNotFoundError:
print("✗ qrencode not found. Install with: apt-get install qrencode", file=sys.stderr)
return False
if __name__ == '__main__':
success = generate_booking_qr()
sys.exit(0 if success else 1)
+3 -1
View File
@@ -28,6 +28,7 @@ services:
REDIS_URL: ${REDIS_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING}
ENVIRONMENT: production
WORKERS: "4"
ports:
@@ -35,6 +36,7 @@ services:
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks:
- app_network
extra_hosts:
@@ -48,7 +50,7 @@ services:
cpus: '1'
memory: 1G
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
+1
View File
@@ -48,6 +48,7 @@ services:
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
networks:
- private_network
- public_network
+2
View File
@@ -39,11 +39,13 @@ http {
# Serve HTML files without .html extension (e.g., /admin -> admin.html)
location ~ ^/([a-zA-Z0-9_-]+)$ {
ssi on;
try_files /$1.html =404;
}
# Serve static files
location / {
ssi on;
try_files $uri $uri/ =404;
# Apply X-Frame-Options to other files
add_header X-Frame-Options "SAMEORIGIN" always;
+219 -3629
View File
File diff suppressed because it is too large Load Diff
+1851
View File
File diff suppressed because it is too large Load Diff
+1287
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -692,6 +692,9 @@
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const pprData = {};
+45 -1
View File
@@ -178,6 +178,35 @@
left: 28px;
}
/* QR code for booking */
.qr-code-container {
position: absolute;
left: 300px;
top: 50%;
transform: translateY(-50%);
background: white;
padding: 5px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.qr-code-container .qr-label {
font-size: 12px;
font-weight: bold;
color: #333;
white-space: nowrap;
}
.qr-code-container img {
display: block;
max-width: 120px;
height: auto;
}
/* Santa hat styles */
.santa-hat {
position: absolute;
@@ -357,6 +386,10 @@
<body>
<header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<div class="qr-code-container">
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
<div class="qr-label">Book Out</div>
</div>
<h1>Flight Information</h1>
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header>
@@ -666,7 +699,7 @@
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 {
// Show ETA if BOOKED_IN
// Show ETA if INBOUND
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>`;
sortTime = arrival.eta;
@@ -847,11 +880,22 @@
return typeMap[flightType] || flightType;
}
// Generate QR code for booking page
function generateBookingQR() {
const qrImg = document.getElementById('bookingQR');
if (qrImg) {
qrImg.src = '/assets/booking-qr.png';
}
}
// Load data on page load
window.addEventListener('load', function() {
// Initialize Christmas mode
initChristmasMode();
// Load booking QR code
generateBookingQR();
loadArrivals();
loadDepartures();
+752
View File
@@ -0,0 +1,752 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journal - PPR System</title>
<link rel="stylesheet" href="admin.css">
<script src="config.js"></script>
<script src="lookups.js"></script>
<style>
.journal-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 5px;
}
.journal-filters label {
display: flex;
flex-direction: column;
font-weight: bold;
color: #333;
}
.journal-filters input,
.journal-filters select {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 0.95rem;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
grid-column: 1 / -1;
justify-content: flex-end;
}
.filter-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
transition: background 0.3s;
}
.btn-apply {
background: #4CAF50;
color: white;
}
.btn-apply:hover {
background: #45a049;
}
.btn-reset {
background: #757575;
color: white;
}
.btn-reset:hover {
background: #616161;
}
.journal-table {
background: white;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.journal-table table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.journal-table thead {
background: #333;
color: white;
}
.journal-table th {
padding: 0.75rem;
text-align: left;
font-weight: bold;
}
.journal-table td {
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.journal-table tbody tr:hover {
background: #f9f9f9;
}
.entity-type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
font-weight: bold;
color: white;
}
.entity-badge-ppr { background: #2196F3; }
.entity-badge-local_flight { background: #FF9800; }
.entity-badge-arrival { background: #4CAF50; }
.entity-badge-departure { background: #9C27B0; }
.entity-badge-overflight { background: #00BCD4; }
.entity-badge-circuit { background: #FFC107; }
.entity-badge-user { background: #795548; }
.entry-text {
color: #555;
word-break: break-word;
}
.entry-datetime {
color: #888;
font-size: 0.85rem;
}
.entry-user {
font-weight: bold;
color: #333;
}
.entry-ip {
color: #999;
font-size: 0.85rem;
}
.stats-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.stat-card {
flex: 1;
min-width: 150px;
padding: 1rem;
background: white;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card .stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #2196F3;
}
.stat-card .stat-label {
color: #888;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.pagination {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 1rem;
}
.pagination button {
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 3px;
transition: background 0.3s;
}
.pagination button:hover {
background: #f0f0f0;
}
.pagination button.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.no-data {
text-align: center;
padding: 2rem;
color: #888;
}
.loading {
text-align: center;
padding: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2196F3;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.export-section {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn-export {
background: #2196F3;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
}
.btn-export:hover {
background: #1976D2;
}
</style>
</head>
<body>
<div class="top-bar">
<div class="title">
<h1>📔 Journal Log</h1>
</div>
<div class="menu-buttons">
<div class="dropdown">
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
⚙️ Menu
</button>
<div class="dropdown-menu" id="adminDropdownMenu">
<a href="#" onclick="window.location.href = '/admin'">📋 Admin</a>
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
</div>
</div>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div>
<div class="container">
<!-- Filters -->
<div class="journal-filters">
<label>
Date From:
<input type="date" id="dateFrom">
</label>
<label>
Date To:
<input type="date" id="dateTo">
</label>
<label>
Entity Type:
<select id="entityType">
<option value="">All Types</option>
<option value="PPR">PPR</option>
<option value="LOCAL_FLIGHT">Local Flight</option>
<option value="ARRIVAL">Arrival</option>
<option value="DEPARTURE">Departure</option>
<option value="OVERFLIGHT">Overflight</option>
<option value="CIRCUIT">Circuit</option>
<option value="USER">User</option>
</select>
</label>
<label>
User:
<input type="text" id="filterUser" placeholder="e.g., john_doe">
</label>
<label>
Entity ID:
<input type="number" id="entityId" placeholder="Optional">
</label>
<label>
Search Text:
<input type="text" id="searchText" placeholder="Search in entries...">
</label>
<div class="filter-buttons">
<button class="btn-apply" onclick="applyFilters()">🔍 Search</button>
<button class="btn-reset" onclick="resetFilters()">↻ Reset</button>
</div>
</div>
<!-- Statistics -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value" id="totalEntries">0</div>
<div class="stat-label">Total Entries</div>
</div>
<div class="stat-card">
<div class="stat-value" id="uniqueUsers">0</div>
<div class="stat-label">Unique Users</div>
</div>
<div class="stat-card">
<div class="stat-value" id="dateRange">-</div>
<div class="stat-label">Date Range</div>
</div>
</div>
<!-- Export -->
<div class="export-section">
<button class="btn-export" onclick="exportToCSV()">📥 Export as CSV</button>
<button class="btn-export" onclick="exportToJSON()">📥 Export as JSON</button>
</div>
<!-- Journal Table -->
<div class="journal-table">
<div id="journal-loading" class="loading" style="display: none;">
<div class="spinner"></div>
Loading journal entries...
</div>
<div id="journal-content" style="display: none;">
<table>
<thead>
<tr>
<th>Date/Time</th>
<th>Entity Type</th>
<th>Entity ID</th>
<th>User</th>
<th>Entry</th>
<th>IP Address</th>
</tr>
</thead>
<tbody id="journal-table-body">
</tbody>
</table>
</div>
<div id="journal-no-data" class="no-data" style="display: none;">
<h3>No Journal Entries Found</h3>
<p>Try adjusting your filters</p>
</div>
<div id="journal-error" class="no-data" style="display: none; color: #d32f2f;">
<h3>⚠️ Error Loading Journal</h3>
<p id="error-message"></p>
</div>
</div>
<!-- Pagination -->
<div id="pagination" class="pagination" style="display: none;">
</div>
</div>
<script>
const API_BASE = window.PPR_CONFIG.apiBase;
let allEntries = [];
let filteredEntries = [];
let currentPage = 1;
const entriesPerPage = 50;
let accessToken = null;
let currentUser = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initializeAuth();
});
async function initializeAuth() {
// Try to get cached token
const cachedToken = localStorage.getItem('ppr_access_token');
const cachedUser = localStorage.getItem('ppr_username');
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
if (cachedToken && cachedUser && tokenExpiry) {
const now = new Date().getTime();
if (now < parseInt(tokenExpiry)) {
// Token is still valid
accessToken = cachedToken;
currentUser = cachedUser;
document.getElementById('current-user').textContent = cachedUser;
setDefaultDates();
loadJournalEntries();
return;
}
}
// No valid cached token, show error or redirect to login
showError('Session expired or not authenticated. Please log in through the admin page.');
}
function setDefaultDates() {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
document.getElementById('dateTo').valueAsDate = today;
}
async function loadJournalEntries() {
showLoading(true);
try {
if (!accessToken) {
showError('No authentication token available.');
showLoading(false);
return;
}
// Use the new search endpoint with default date range
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
const params = new URLSearchParams({
date_from: dateFrom,
date_to: dateTo,
limit: 500
});
const response = await fetch(`${API_BASE}/journal/search/all?${params}`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) {
showError('Session expired. Please log in again through the admin page.');
showLoading(false);
return;
}
if (!response.ok) {
throw new Error(`Failed to load journal entries: ${response.statusText}`);
}
allEntries = await response.json();
showLoading(false);
updateStats();
applyFilters();
} catch (error) {
console.error('Error loading journal:', error);
showError(error.message);
showLoading(false);
}
}
function applyFilters() {
showLoading(true);
if (!accessToken) {
showError('No authentication token available. Please log in again.');
showLoading(false);
return;
}
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
const entityType = document.getElementById('entityType').value;
const filterUser = document.getElementById('filterUser').value;
const entityId = document.getElementById('entityId').value;
const searchText = document.getElementById('searchText').value.toLowerCase();
// Build API request with filters
const params = new URLSearchParams();
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
if (entityType) params.append('entity_type', entityType);
if (entityId) params.append('entity_id', entityId);
if (filterUser) params.append('user', filterUser);
params.append('limit', 500);
fetch(`${API_BASE}/journal/search/all?${params}`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
.then(response => {
if (response.status === 401) {
throw new Error('Session expired. Please log in again through the admin page.');
}
if (!response.ok) throw new Error(`Search failed: ${response.statusText}`);
return response.json();
})
.then(data => {
filteredEntries = data;
// Apply client-side text search if any
if (searchText) {
filteredEntries = filteredEntries.filter(entry =>
entry.entry.toLowerCase().includes(searchText)
);
}
currentPage = 1;
displayEntries();
updatePagination();
showLoading(false);
})
.catch(error => {
console.error('Error searching journal:', error);
showError(error.message);
showLoading(false);
});
}
function displayEntries() {
const start = (currentPage - 1) * entriesPerPage;
const end = start + entriesPerPage;
const pageEntries = filteredEntries.slice(start, end);
if (pageEntries.length === 0) {
document.getElementById('journal-table-body').innerHTML = '';
document.getElementById('journal-content').style.display = 'none';
document.getElementById('journal-no-data').style.display = 'block';
return;
}
const tableBody = document.getElementById('journal-table-body');
tableBody.innerHTML = pageEntries.map(entry => `
<tr>
<td class="entry-datetime">${formatDateTime(entry.entry_dt)}</td>
<td><span class="entity-type-badge entity-badge-${entry.entity_type.toLowerCase()}">${entry.entity_type}</span></td>
<td>${entry.entity_id}</td>
<td class="entry-user">${entry.user}</td>
<td class="entry-text">${escapeHtml(entry.entry)}</td>
<td class="entry-ip">${entry.ip || '-'}</td>
</tr>
`).join('');
document.getElementById('journal-content').style.display = 'block';
document.getElementById('journal-no-data').style.display = 'none';
document.getElementById('journal-error').style.display = 'none';
}
function updatePagination() {
const totalPages = Math.ceil(filteredEntries.length / entriesPerPage);
if (totalPages <= 1) {
document.getElementById('pagination').style.display = 'none';
return;
}
const paginationDiv = document.getElementById('pagination');
paginationDiv.innerHTML = '';
if (currentPage > 1) {
const prevBtn = document.createElement('button');
prevBtn.textContent = '← Previous';
prevBtn.onclick = () => {
currentPage--;
displayEntries();
updatePagination();
};
paginationDiv.appendChild(prevBtn);
}
// Show page numbers
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
const firstBtn = document.createElement('button');
firstBtn.textContent = '1';
firstBtn.onclick = () => {
currentPage = 1;
displayEntries();
updatePagination();
};
paginationDiv.appendChild(firstBtn);
if (startPage > 2) {
const dots = document.createElement('span');
dots.textContent = '...';
dots.style.padding = '0.5rem 0.5rem';
paginationDiv.appendChild(dots);
}
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
btn.textContent = i;
if (i === currentPage) btn.className = 'active';
btn.onclick = () => {
currentPage = i;
displayEntries();
updatePagination();
};
paginationDiv.appendChild(btn);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const dots = document.createElement('span');
dots.textContent = '...';
dots.style.padding = '0.5rem 0.5rem';
paginationDiv.appendChild(dots);
}
const lastBtn = document.createElement('button');
lastBtn.textContent = totalPages;
lastBtn.onclick = () => {
currentPage = totalPages;
displayEntries();
updatePagination();
};
paginationDiv.appendChild(lastBtn);
}
if (currentPage < totalPages) {
const nextBtn = document.createElement('button');
nextBtn.textContent = 'Next →';
nextBtn.onclick = () => {
currentPage++;
displayEntries();
updatePagination();
};
paginationDiv.appendChild(nextBtn);
}
paginationDiv.style.display = 'flex';
}
function updateStats() {
document.getElementById('totalEntries').textContent = allEntries.length;
const uniqueUsers = new Set(allEntries.map(e => e.user)).size;
document.getElementById('uniqueUsers').textContent = uniqueUsers;
if (allEntries.length > 0) {
const dates = allEntries
.map(e => new Date(e.entry_dt))
.sort((a, b) => a - b);
const earliest = formatDate(dates[0]);
const latest = formatDate(dates[dates.length - 1]);
document.getElementById('dateRange').textContent = `${earliest} to ${latest}`;
}
}
function resetFilters() {
setDefaultDates();
document.getElementById('entityType').value = '';
document.getElementById('filterUser').value = '';
document.getElementById('entityId').value = '';
document.getElementById('searchText').value = '';
applyFilters();
}
function showLoading(show) {
document.getElementById('journal-loading').style.display = show ? 'block' : 'none';
}
function showError(message) {
document.getElementById('error-message').textContent = message;
document.getElementById('journal-error').style.display = 'block';
document.getElementById('journal-content').style.display = 'none';
document.getElementById('journal-no-data').style.display = 'none';
}
function exportToCSV() {
let csv = 'Date/Time,Entity Type,Entity ID,User,Entry,IP Address\n';
filteredEntries.forEach(entry => {
const row = [
formatDateTime(entry.entry_dt),
entry.entity_type,
entry.entity_id,
entry.user,
`"${entry.entry.replace(/"/g, '""')}"`,
entry.ip || ''
];
csv += row.join(',') + '\n';
});
downloadFile(csv, 'journal_export.csv', 'text/csv');
}
function exportToJSON() {
const json = JSON.stringify(filteredEntries, null, 2);
downloadFile(json, 'journal_export.json', 'application/json');
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function formatDateTime(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit'
});
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function logout() {
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
window.location.href = '/admin';
}
</script>
</body>
</html>
+137 -4
View File
@@ -163,15 +163,26 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
const resultsDiv = document.getElementById(resultsId);
if (config.isAircraft) {
// Aircraft lookup: auto-populate on single match, format input on no match
// Aircraft lookup: auto-populate on single match, mark form for auto-save on no match
if (!results || results.length === 0) {
// Format the aircraft registration and auto-populate
// Format the aircraft registration
const formatted = formatAircraftRegistration(searchTerm);
const field = document.getElementById(fieldId);
if (field) {
field.value = formatted;
// Mark the form for auto-saving this aircraft
const form = field.closest('form');
if (form) {
form.setAttribute('data-unsaved-aircraft', fieldId);
}
}
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
// Show message that type will be saved
resultsDiv.innerHTML = `
<div class="aircraft-no-match">
No match found - aircraft type will be saved automatically when you submit
</div>
`;
} else if (results.length === 1) {
// Single match - auto-populate
const aircraft = results[0];
@@ -183,7 +194,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Auto-populate the form fields
const field = document.getElementById(fieldId);
if (field) field.value = aircraft.registration;
if (field) {
field.value = aircraft.registration;
// Clear the unsaved aircraft flag since we found a match
const form = field.closest('form');
if (form) {
form.removeAttribute('data-unsaved-aircraft');
}
}
// Also populate type field
let typeFieldId;
@@ -208,6 +226,14 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
Multiple matches found (${results.length}) - please be more specific
</div>
`;
// Clear the unsaved aircraft flag since multiple matches found
const field = document.getElementById(fieldId);
if (field) {
const form = field.closest('form');
if (form) {
form.removeAttribute('data-unsaved-aircraft');
}
}
}
} else {
// Airport lookup: show list of options with keyboard navigation
@@ -501,3 +527,110 @@ function selectBookInAircraft(registration) {
function selectBookInArrivalAirport(icaoCode) {
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
}
// Save user aircraft type for future lookups
async function saveUserAircraft(registrationFieldId, resultsDivId) {
const regField = document.getElementById(registrationFieldId);
if (!regField || !regField.value.trim()) {
showNotification('Please enter a registration first', true);
return;
}
// Determine the type field ID based on registration field
let typeFieldId;
if (registrationFieldId === 'ac_reg') {
typeFieldId = 'ac_type';
} else if (registrationFieldId === 'local_registration') {
typeFieldId = 'local_type';
} else if (registrationFieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (registrationFieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
const typeField = document.getElementById(typeFieldId);
if (!typeField || !typeField.value.trim()) {
showNotification('Please enter an aircraft type first', true);
return;
}
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registration: regField.value.trim(),
type_code: typeField.value.trim()
})
});
if (response.ok) {
const data = await response.json();
showNotification('Aircraft type saved for future use');
// Clear the results div
const resultsDiv = document.getElementById(resultsDivId);
if (resultsDiv) {
resultsDiv.innerHTML = '';
}
} else {
const error = await response.json();
showNotification(error.detail || 'Failed to save aircraft', true);
}
} catch (error) {
console.error('Error saving aircraft:', error);
showNotification('Error saving aircraft', true);
}
}
// Auto-save unsaved aircraft before form submission
async function autoSaveUnsavedAircraft(form) {
const unsavedFieldId = form.getAttribute('data-unsaved-aircraft');
if (!unsavedFieldId) return; // No unsaved aircraft to save
const regField = document.getElementById(unsavedFieldId);
if (!regField || !regField.value.trim()) return;
// Determine the type field ID
let typeFieldId;
if (unsavedFieldId === 'ac_reg') {
typeFieldId = 'ac_type';
} else if (unsavedFieldId === 'local_registration') {
typeFieldId = 'local_type';
} else if (unsavedFieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (unsavedFieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
const typeField = document.getElementById(typeFieldId);
if (!typeField || !typeField.value.trim()) return;
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registration: regField.value.trim(),
type_code: typeField.value.trim()
})
});
if (response.ok) {
// Successfully saved, remove the flag
form.removeAttribute('data-unsaved-aircraft');
console.log('Auto-saved aircraft type for', regField.value.trim());
} else if (response.status === 400) {
// Already exists, just remove the flag
form.removeAttribute('data-unsaved-aircraft');
} else {
console.error('Failed to auto-save aircraft');
}
} catch (error) {
console.error('Error auto-saving aircraft:', error);
}
}
+1277
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -365,6 +365,9 @@
<button class="btn btn-secondary" onclick="window.location.href='admin'">
← Back to Admin
</button>
<button class="btn btn-secondary" onclick="window.location.href='journal'">
📔 Journal Log
</button>
</div>
<div class="title">
<h1 id="tower-title">📊 PPR Reports</h1>
+927
View File
@@ -0,0 +1,927 @@
<!-- Login Modal -->
<div id="loginModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>PPR Admin Login</h2>
</div>
<div class="modal-body">
<form id="login-form">
<div class="form-group">
<label for="login-username">Username:</label>
<input type="text" id="login-username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="login-password">Password:</label>
<input type="password" id="login-password" name="password" required>
</div>
<div id="login-error" style="color: #dc3545; margin: 1rem 0; display: none;"></div>
<div class="form-actions" style="border-top: none; padding-top: 0;">
<button type="submit" class="btn btn-success" id="login-btn">
🔐 Login
</button>
</div>
</form>
</div>
</div>
</div>
<!-- PPR Detail/Edit Modal -->
<div id="pprModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">PPR Details</h2>
<button class="close" onclick="closePPRModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
🛬 Land
</button>
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
🛫 Depart
</button>
</div>
<form id="ppr-form">
<input type="hidden" id="ppr-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="ac_reg">Aircraft Registration *</label>
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
<div id="aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="ac_type">Aircraft Type *</label>
<input type="text" id="ac_type" name="ac_type" required tabindex="-1">
</div>
<div class="form-group">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
</div>
<div class="form-group">
<label for="captain">Captain *</label>
<input type="text" id="captain" name="captain" required>
</div>
<div class="form-group">
<label for="in_from">Arriving From *</label>
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
<div id="arrival-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="eta">ETA (Local Time) *</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
<select id="eta-time" name="eta-time" required style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_in">POB Inbound *</label>
<input type="number" id="pob_in" name="pob_in" required min="1">
</div>
<div class="form-group">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel" tabindex="-1">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
</select>
</div>
<div class="form-group">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
<div id="departure-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="etd">ETD (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_out">POB Outbound</label>
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" tabindex="-1">
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input type="tel" id="phone" name="phone" tabindex="-1">
</div>
<div class="form-group full-width">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closePPRModal()">
Close
</button>
<button type="button" class="btn btn-danger" id="btn-cancel" onclick="updateStatus('CANCELED')">
❌ Cancel PPR
</button>
<button type="submit" class="btn btn-success">
💾 Save Changes
</button>
</div>
</form>
<div class="journal-section" id="journal-section">
<h3>Activity Journal</h3>
<div id="journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
<!-- Local Flight (Book Out) Modal -->
<div id="localFlightModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="local-flight-modal-title">Book Out</h2>
<button class="close" onclick="closeModal('localFlightModal')">&times;</button>
</div>
<div class="modal-body">
<form id="local-flight-form">
<input type="hidden" id="local-flight-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="local_registration">Aircraft Registration *</label>
<input type="text" id="local_registration" name="registration" required oninput="handleLocalAircraftLookup(this.value)" tabindex="1">
<div id="local-aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="local_type">Aircraft Type</label>
<input type="text" id="local_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
</div>
<div class="form-group">
<label for="local_callsign">Callsign (optional)</label>
<input type="text" id="local_callsign" name="callsign" placeholder="If different from registration" tabindex="6">
</div>
<div class="form-group">
<label for="local_pob">Persons on Board *</label>
<input type="number" id="local_pob" name="pob" required min="1" tabindex="2">
</div>
<div class="form-group">
<label for="local_flight_type">Flight Type *</label>
<select id="local_flight_type" name="flight_type" required tabindex="5" onchange="handleFlightTypeChange(this.value)">
<option value="LOCAL">Local Flight</option>
<option value="CIRCUITS">Circuits</option>
<option value="DEPARTURE">Departure to Other Airport</option>
</select>
</div>
<div class="form-group">
<label for="local_duration">Duration (minutes)</label>
<input type="number" id="local_duration" name="duration" min="5" max="480" value="45" placeholder="Duration in minutes" tabindex="7">
</div>
<div class="form-group" id="departure-destination-group" style="display: none;">
<label for="local_out_to" id="departure-destination-label">Destination Airport</label>
<input type="text" id="local_out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleLocalOutToAirportLookup(this.value)" tabindex="3">
<div id="local-out-to-lookup-results"></div>
</div>
<div class="form-group">
<label for="local_etd_time">ETD (Estimated Time of Departure) *</label>
<select id="local_etd_time" name="etd_time" required>
<option value="">Select Time</option>
</select>
</div>
<div class="form-group full-width">
<label for="local_notes">Notes</label>
<textarea id="local_notes" name="notes" rows="3" placeholder="e.g., destination, any special requirements"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('localFlightModal')">
Close
</button>
<button type="submit" class="btn btn-success">
🛫 Book Out
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Local Flight Edit Modal -->
<div id="localFlightEditModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="local-flight-edit-title">Local Flight Details</h2>
<button class="close" onclick="closeLocalFlightEditModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="local-btn-departed" class="btn btn-primary btn-sm" onclick="updateLocalFlightStatus('DEPARTED')" style="display: none;">
🛫 Mark Departed
</button>
<button id="local-btn-landed" class="btn btn-warning btn-sm" onclick="updateLocalFlightStatus('LANDED')" style="display: none;">
🛬 Land
</button>
<button id="local-btn-cancel" class="btn btn-danger btn-sm" onclick="updateLocalFlightStatus('CANCELLED')" style="display: none;">
❌ Cancel Flight
</button>
</div>
<form id="local-flight-edit-form">
<input type="hidden" id="local-edit-flight-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="local_edit_registration">Aircraft Registration</label>
<input type="text" id="local_edit_registration" name="registration">
</div>
<div class="form-group">
<label for="local_edit_type">Aircraft Type</label>
<input type="text" id="local_edit_type" name="type">
</div>
<div class="form-group">
<label for="local_edit_callsign">Callsign</label>
<input type="text" id="local_edit_callsign" name="callsign">
</div>
<div class="form-group">
<label for="local_edit_pob">POB</label>
<input type="number" id="local_edit_pob" name="pob" min="1">
</div>
<div class="form-group">
<label for="local_edit_flight_type">Flight Type</label>
<select id="local_edit_flight_type" name="flight_type">
<option value="LOCAL">Local Flight</option>
<option value="CIRCUITS">Circuits</option>
<option value="DEPARTURE">Departure</option>
</select>
</div>
<div class="form-group">
<label for="local_edit_duration">Duration (minutes)</label>
<input type="number" id="local_edit_duration" name="duration" min="5" max="480">
</div>
<div class="form-group">
<label for="local_edit_takeoff_dt">Takeoff Time</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="local_edit_departure_date" name="departure_date" style="flex: 1;">
<input type="time" id="local_edit_departure_time" name="departure_time" style="flex: 1;">
</div>
</div>
<div class="form-group" id="local-edit-landing-group">
<label for="local_edit_landed_dt">Landing Time</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="local_edit_landed_date" name="landed_date" style="flex: 1;">
<input type="time" id="local_edit_landed_time" name="landed_time" style="flex: 1;">
</div>
</div>
<div class="form-group full-width">
<label for="local_edit_notes">Notes</label>
<textarea id="local_edit_notes" name="notes" rows="3"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeLocalFlightEditModal()">
Close
</button>
<button type="submit" class="btn btn-success">
💾 Save Changes
</button>
</div>
</form>
<!-- Touch & Go Records Section (for all local flight types) -->
<div id="circuits-section" style="display: none; margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>✈️ Touch & Go Records</h3>
<div id="circuits-list" style="margin-top: 1rem;">
<p style="color: #666; font-style: italic;">Loading circuits...</p>
</div>
</div>
<!-- Journal Section -->
<div id="local-flight-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="local-flight-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
<!-- Book In Modal -->
<div id="bookInModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Book In</h2>
<button class="close" onclick="closeModal('bookInModal')">&times;</button>
</div>
<div class="modal-body">
<form id="book-in-form">
<input type="hidden" id="book-in-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="book_in_registration">Aircraft Registration *</label>
<input type="text" id="book_in_registration" name="registration" required oninput="handleBookInAircraftLookup(this.value)" tabindex="1">
<div id="book-in-aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="book_in_type">Aircraft Type</label>
<input type="text" id="book_in_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
</div>
<div class="form-group">
<label for="book_in_callsign">Callsign (optional)</label>
<input type="text" id="book_in_callsign" name="callsign" placeholder="If different from registration" tabindex="5">
</div>
<div class="form-group">
<label for="book_in_pob">Persons on Board *</label>
<input type="number" id="book_in_pob" name="pob" required min="1" tabindex="2">
</div>
<div class="form-group">
<label for="book_in_from">Coming From (Airport) *</label>
<input type="text" id="book_in_from" name="in_from" placeholder="ICAO Code or Airport Name" required oninput="handleBookInArrivalAirportLookup(this.value)" tabindex="3">
<div id="book-in-arrival-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="book_in_eta_time">ETA (Estimated Time of Arrival) *</label>
<select id="book_in_eta_time" name="eta_time" required>
<option value="">Select Time</option>
</select>
</div>
<div class="form-group full-width">
<label for="book_in_notes">Notes</label>
<textarea id="book_in_notes" name="notes" rows="3" placeholder="e.g., any special requirements"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('bookInModal')">
Close
</button>
<button type="submit" class="btn btn-success">
🛬 Book In
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Overflight Modal -->
<div id="overflightModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Register Overflight</h2>
<button class="close" onclick="closeModal('overflightModal')">&times;</button>
</div>
<div class="modal-body">
<form id="overflight-form">
<input type="hidden" id="overflight-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="overflight_registration">Callsign/Registration *</label>
<input type="text" id="overflight_registration" name="registration" required oninput="handleOverflightAircraftLookup(this.value)" tabindex="1">
<div id="overflight-aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="overflight_type">Aircraft Type</label>
<input type="text" id="overflight_type" name="type" placeholder="e.g., C172, PA34, AA5" tabindex="2">
</div>
<div class="form-group">
<label for="overflight_pob">Persons on Board</label>
<input type="number" id="overflight_pob" name="pob" min="1" tabindex="3">
</div>
<div class="form-group">
<label for="overflight_departure_airfield">Departure Airfield</label>
<input type="text" id="overflight_departure_airfield" name="departure_airfield" placeholder="ICAO Code or Airport Name" oninput="handleOverflightDepartureAirportLookup(this.value)" tabindex="4">
<div id="overflight-departure-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="overflight_destination_airfield">Destination Airfield</label>
<input type="text" id="overflight_destination_airfield" name="destination_airfield" placeholder="ICAO Code or Airport Name" oninput="handleOverflightDestinationAirportLookup(this.value)" tabindex="5">
<div id="overflight-destination-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="overflight_call_dt">Time of Call *</label>
<input type="datetime-local" id="overflight_call_dt" name="call_dt" required tabindex="6">
</div>
<div class="form-group full-width">
<label for="overflight_notes">Notes</label>
<textarea id="overflight_notes" name="notes" rows="3" placeholder="e.g., flight plan, special remarks" tabindex="7"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('overflightModal')">
Close
</button>
<button type="submit" class="btn btn-success">
🔄 Register Overflight
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Overflight Edit Modal -->
<div id="overflightEditModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="overflight-edit-title">Overflight Details</h2>
<button class="close" onclick="closeModal('overflightEditModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="overflight-btn-qsy" class="btn btn-primary btn-sm" onclick="showOverflightQSYModal()" style="display: none;">
📡 Mark QSY
</button>
<button id="overflight-btn-cancel" class="btn btn-danger btn-sm" onclick="confirmCancelOverflight()" style="display: none;">
❌ Cancel
</button>
</div>
<form id="overflight-edit-form">
<input type="hidden" id="overflight-edit-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="overflight_edit_registration">Callsign/Registration</label>
<input type="text" id="overflight_edit_registration" name="registration" readonly>
</div>
<div class="form-group">
<label for="overflight_edit_type">Aircraft Type</label>
<input type="text" id="overflight_edit_type" name="type">
</div>
<div class="form-group">
<label for="overflight_edit_pob">Persons on Board</label>
<input type="number" id="overflight_edit_pob" name="pob" min="1">
</div>
<div class="form-group">
<label for="overflight_edit_departure_airfield">Departure Airfield</label>
<input type="text" id="overflight_edit_departure_airfield" name="departure_airfield">
</div>
<div class="form-group">
<label for="overflight_edit_destination_airfield">Destination Airfield</label>
<input type="text" id="overflight_edit_destination_airfield" name="destination_airfield">
</div>
<div class="form-group">
<label for="overflight_edit_call_dt">Time of Call</label>
<input type="datetime-local" id="overflight_edit_call_dt" name="call_dt">
</div>
<div class="form-group">
<label for="overflight_edit_status">Status</label>
<input type="text" id="overflight_edit_status" name="status" readonly>
</div>
<div class="form-group">
<label for="overflight_edit_qsy_dt">QSY Time</label>
<input type="datetime-local" id="overflight_edit_qsy_dt" name="qsy_dt">
</div>
<div class="form-group full-width">
<label for="overflight_edit_notes">Notes</label>
<textarea id="overflight_edit_notes" name="notes" rows="3"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('overflightEditModal')">
Close
</button>
<button type="submit" class="btn btn-success">
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Departure Edit Modal -->
<div id="departureEditModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="departure-edit-title">Departure Details</h2>
<button class="close" onclick="closeDepartureEditModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="departure-btn-departed" class="btn btn-primary btn-sm" onclick="updateDepartureStatus('DEPARTED')" style="display: none;">
🛫 Mark Departed
</button>
<button id="departure-btn-cancel" class="btn btn-danger btn-sm" onclick="updateDepartureStatus('CANCELLED')" style="display: none;">
❌ Cancel Departure
</button>
</div>
<form id="departure-edit-form">
<input type="hidden" id="departure-edit-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="departure_edit_registration">Aircraft Registration</label>
<input type="text" id="departure_edit_registration" name="registration">
</div>
<div class="form-group">
<label for="departure_edit_type">Aircraft Type</label>
<input type="text" id="departure_edit_type" name="type">
</div>
<div class="form-group">
<label for="departure_edit_callsign">Callsign</label>
<input type="text" id="departure_edit_callsign" name="callsign">
</div>
<div class="form-group">
<label for="departure_edit_pob">Persons on Board</label>
<input type="number" id="departure_edit_pob" name="pob" min="1">
</div>
<div class="form-group">
<label for="departure_edit_out_to">Destination</label>
<input type="text" id="departure_edit_out_to" name="out_to">
</div>
<div class="form-group">
<label for="departure_edit_etd">ETD (UTC)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="departure_edit_etd_date" name="etd_date" style="flex: 1;">
<input type="time" id="departure_edit_etd_time" name="etd_time" style="flex: 1;">
</div>
</div>
<div class="form-group">
<label>Takeoff Time (UTC)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="departure_edit_takeoff_date" name="takeoff_date" style="flex: 1;">
<input type="time" id="departure_edit_takeoff_time" name="takeoff_time" style="flex: 1;">
</div>
</div>
<div class="form-group full-width">
<label for="departure_edit_notes">Notes</label>
<textarea id="departure_edit_notes" name="notes" rows="3"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeDepartureEditModal()">
Close
</button>
<button type="submit" class="btn btn-success">
Save Changes
</button>
</div>
</form>
<!-- Journal Section -->
<div id="departure-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="departure-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
<!-- Arrival Edit Modal -->
<div id="arrivalEditModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="arrival-edit-title">Arrival Details</h2>
<button class="close" onclick="closeArrivalEditModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="arrival-btn-landed" class="btn btn-primary btn-sm" onclick="updateArrivalStatus('LANDED')" style="display: none;">
🛬 Mark Landed
</button>
<button id="arrival-btn-cancel" class="btn btn-danger btn-sm" onclick="updateArrivalStatus('CANCELLED')" style="display: none;">
❌ Cancel Arrival
</button>
</div>
<form id="arrival-edit-form">
<input type="hidden" id="arrival-edit-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="arrival_edit_registration">Aircraft Registration</label>
<input type="text" id="arrival_edit_registration" name="registration">
</div>
<div class="form-group">
<label for="arrival_edit_type">Aircraft Type</label>
<input type="text" id="arrival_edit_type" name="type">
</div>
<div class="form-group">
<label for="arrival_edit_callsign">Callsign</label>
<input type="text" id="arrival_edit_callsign" name="callsign">
</div>
<div class="form-group">
<label for="arrival_edit_in_from">Origin Airport</label>
<input type="text" id="arrival_edit_in_from" name="in_from">
</div>
<div class="form-group">
<label for="arrival_edit_pob">POB (Persons on Board)</label>
<input type="number" id="arrival_edit_pob" name="pob" min="1">
</div>
<div class="form-group full-width">
<label for="arrival_edit_notes">Notes</label>
<textarea id="arrival_edit_notes" name="notes" rows="3"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeArrivalEditModal()">
Close
</button>
<button type="submit" class="btn btn-success">
Save Changes
</button>
</div>
</form>
<!-- Journal Section -->
<div id="arrival-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
<h3>📋 Activity Journal</h3>
<div id="arrival-journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
<!-- Table Help Modal -->
<div id="tableHelpModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Table Information</h2>
<button class="close" onclick="closeModal('tableHelpModal')">&times;</button>
</div>
<div class="modal-body" id="tableHelpContent">
<!-- Content will be populated by JavaScript -->
</div>
<div class="modal-footer">
<button class="btn btn-info" onclick="closeModal('tableHelpModal')">Close</button>
</div>
</div>
</div>
<!-- User Management Modal -->
<div id="userManagementModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>User Management</h2>
<button class="close" onclick="closeModal('userManagementModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions" style="margin-bottom: 1rem;">
<button class="btn btn-success" onclick="openUserCreateModal()">
Create New User
</button>
</div>
<div id="users-loading" class="loading">
<div class="spinner"></div>
Loading users...
</div>
<div id="users-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
</tbody>
</table>
</div>
<div id="users-no-data" class="no-data" style="display: none;">
<h3>No users found</h3>
<p>No users are configured in the system.</p>
</div>
</div>
</div>
</div>
<!-- User Create/Edit Modal -->
<div id="userModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="user-modal-title">Create User</h2>
<button class="close" onclick="closeUserModal()">&times;</button>
</div>
<div class="modal-body">
<form id="user-form">
<input type="hidden" id="user-id" name="id">
<div class="form-grid">
<div class="form-group full-width">
<label for="user-username">Username *</label>
<input type="text" id="user-username" name="username" required>
</div>
<div class="form-group full-width">
<label for="user-password">Password *</label>
<input type="password" id="user-password" name="password" required>
<small style="color: #666; font-size: 0.8rem;">Leave blank when editing to keep current password</small>
</div>
<div class="form-group full-width">
<label for="user-role">Role *</label>
<select id="user-role" name="role" required>
<option value="READ_ONLY">Read Only - View only access</option>
<option value="OPERATOR">Operator - PPR management access</option>
<option value="ADMINISTRATOR">Administrator - Full access</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeUserModal()">
Close
</button>
<button type="submit" class="btn btn-success">
💾 Save User
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Change User Password Modal -->
<div id="changePasswordModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="change-password-title">Change User Password</h2>
<button class="close" onclick="closeChangePasswordModal()">&times;</button>
</div>
<div class="modal-body">
<form id="change-password-form">
<div class="form-group full-width">
<label for="change-password-username" style="font-weight: bold;">Username</label>
<input type="text" id="change-password-username" name="username" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
</div>
<div class="form-group full-width">
<label for="change-password-new">New Password *</label>
<input type="password" id="change-password-new" name="new_password" required>
</div>
<div class="form-group full-width">
<label for="change-password-confirm">Confirm New Password *</label>
<input type="password" id="change-password-confirm" name="confirm_password" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeChangePasswordModal()">
Close
</button>
<button type="submit" class="btn btn-warning">
🔐 Change Password
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<!-- User Aircraft Management Modal -->
<div id="userAircraftModal" class="modal">
<div class="modal-content" style="max-width: 1000px;">
<div class="modal-header">
<h2>User Aircraft Management</h2>
<button class="close" onclick="closeModal('userAircraftModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions" style="margin-bottom: 1rem;">
<div class="form-group" style="display: inline-block; margin-right: 1rem;">
<label for="user-aircraft-search" style="display: inline; margin-right: 0.5rem;">Search:</label>
<input type="text" id="user-aircraft-search" placeholder="Filter by registration or type..." style="width: 250px;">
</div>
<button class="btn btn-info" onclick="loadUserAircraft()">
🔄 Refresh
</button>
</div>
<div id="user-aircraft-loading" class="loading">
<div class="spinner"></div>
Loading user aircraft...
</div>
<div id="user-aircraft-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Registration</th>
<th>Type</th>
<th>Added By</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-aircraft-table-body">
</tbody>
</table>
</div>
<div id="user-aircraft-no-data" class="no-data" style="display: none;">
<h3>No user aircraft found</h3>
<p>No custom aircraft types have been saved yet.</p>
</div>
</div>
</div>
</div>
<!-- User Aircraft Edit Modal -->
<div id="userAircraftEditModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="user-aircraft-edit-title">Edit User Aircraft</h2>
<button class="close" onclick="closeModal('userAircraftEditModal')">&times;</button>
</div>
<div class="modal-body">
<form id="user-aircraft-edit-form">
<div class="form-group full-width">
<label for="edit-aircraft-registration">Registration *</label>
<input type="text" id="edit-aircraft-registration" name="registration" required readonly style="background-color: #f5f5f5;">
</div>
<div class="form-group full-width">
<label for="edit-aircraft-type">Aircraft Type *</label>
<input type="text" id="edit-aircraft-type" name="type_code" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeModal('userAircraftEditModal')">
Cancel
</button>
<button type="submit" class="btn btn-warning">
💾 Save Changes
</button>
<button type="button" class="btn btn-danger" onclick="deleteUserAircraft()">
🗑️ Delete
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Timestamp Modal for Landing/Departure -->
<div id="timestampModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2 id="timestamp-modal-title">Confirm Landing Time</h2>
<button class="close" onclick="closeTimestampModal()">&times;</button>
</div>
<div class="modal-body">
<form id="timestamp-form">
<div class="form-group">
<label for="event-timestamp">Event Time (UTC) *</label>
<input type="datetime-local" id="event-timestamp" name="timestamp" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeTimestampModal()">
Close
</button>
<button type="submit" class="btn btn-success" id="timestamp-submit-btn">
Confirm
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Circuit Modal for recording touch-and-go events -->
<div id="circuitModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>Record Circuit (Touch & Go)</h2>
<button class="close" onclick="closeCircuitModal()">&times;</button>
</div>
<div class="modal-body">
<form id="circuit-form">
<div class="form-group">
<label for="circuit-timestamp">Circuit Time (UTC) *</label>
<input type="datetime-local" id="circuit-timestamp" name="timestamp" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeCircuitModal()">
Close
</button>
<button type="submit" class="btn btn-success">
Record Circuit
</button>
</div>
</form>
</div>
</div>
</div>
+3013
View File
File diff suppressed because it is too large Load Diff