Compare commits

...

2 Commits

Author SHA1 Message Date
jamesp 78d738b0ee Drone flights and Bulk Logging WIPs 2026-06-19 17:27:33 -04:00
jamesp 1952b89ecf PPR ACK and Bulk Logging start 2026-06-15 15:45:58 -04:00
27 changed files with 3698 additions and 26 deletions
@@ -0,0 +1,27 @@
"""Add PPR paper strip acknowledgement fields
Revision ID: 008_ppr_strip_acknowledgement
Revises: 007_ppr_activated_status
Create Date: 2026-06-15 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '008_ppr_strip_acknowledgement'
down_revision = '007_ppr_activated_status'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True))
op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True))
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
op.execute("UPDATE local_flights SET pob = 1 WHERE pob IS NULL")
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=False)
op.drop_column('submitted', 'acknowledged_by')
op.drop_column('submitted', 'acknowledged_dt')
@@ -0,0 +1,90 @@
"""Add drone flight requests
Revision ID: 009_drone_requests
Revises: 008_ppr_strip_acknowledgement
Create Date: 2026-06-19 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '009_drone_requests'
down_revision = '008_ppr_strip_acknowledgement'
branch_labels = None
depends_on = None
drone_status = sa.Enum(
'NEW',
'APPROVED',
'DENIED',
'PENDING',
'CANCELED',
'INFLIGHT',
'COMPLETED',
name='dronerequeststatus',
)
def upgrade() -> None:
op.create_table(
'drone_requests',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('reference_number', sa.String(length=24), nullable=False),
sa.Column('public_token', sa.String(length=128), nullable=True),
sa.Column('status', drone_status, nullable=False),
sa.Column('operator_name', sa.String(length=128), nullable=False),
sa.Column('operator_id', sa.String(length=64), nullable=True),
sa.Column('flyer_name', sa.String(length=128), nullable=True),
sa.Column('flyer_id', sa.String(length=64), nullable=True),
sa.Column('email', sa.String(length=128), nullable=False),
sa.Column('phone', sa.String(length=32), nullable=True),
sa.Column('flight_date', sa.Date(), nullable=True),
sa.Column('estimated_takeoff_time', sa.String(length=8), nullable=True),
sa.Column('estimated_completion_time', sa.String(length=8), nullable=True),
sa.Column('estimated_takeoff_at', sa.DateTime(), nullable=False),
sa.Column('estimated_completion_at', sa.DateTime(), nullable=False),
sa.Column('maximum_elevation_ft_amsl', sa.Integer(), nullable=False),
sa.Column('location_description', sa.Text(), nullable=True),
sa.Column('location_latitude', sa.Float(), nullable=False),
sa.Column('location_longitude', sa.Float(), nullable=False),
sa.Column('location_inside_frz', sa.Boolean(), nullable=True),
sa.Column('prototype_overlay', sa.JSON(), nullable=True),
sa.Column('applicant_notes', sa.Text(), nullable=True),
sa.Column('operator_comments', sa.Text(), nullable=True),
sa.Column('submitted_via', sa.String(length=32), nullable=False, server_default='PUBLIC'),
sa.Column('submitted_ip', sa.String(length=45), nullable=True),
sa.Column('created_by', sa.String(length=50), nullable=True),
sa.Column('submitted_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('status_changed_at', sa.DateTime(), nullable=True),
sa.Column('status_changed_by', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('reference_number'),
)
op.create_index(op.f('ix_drone_requests_created_by'), 'drone_requests', ['created_by'], unique=False)
op.create_index(op.f('ix_drone_requests_email'), 'drone_requests', ['email'], unique=False)
op.create_index(op.f('ix_drone_requests_estimated_completion_at'), 'drone_requests', ['estimated_completion_at'], unique=False)
op.create_index(op.f('ix_drone_requests_estimated_takeoff_at'), 'drone_requests', ['estimated_takeoff_at'], unique=False)
op.create_index(op.f('ix_drone_requests_flight_date'), 'drone_requests', ['flight_date'], unique=False)
op.create_index(op.f('ix_drone_requests_operator_name'), 'drone_requests', ['operator_name'], unique=False)
op.create_index(op.f('ix_drone_requests_public_token'), 'drone_requests', ['public_token'], unique=True)
op.create_index(op.f('ix_drone_requests_reference_number'), 'drone_requests', ['reference_number'], unique=True)
op.create_index(op.f('ix_drone_requests_status'), 'drone_requests', ['status'], unique=False)
op.create_index(op.f('ix_drone_requests_submitted_at'), 'drone_requests', ['submitted_at'], unique=False)
op.create_index('idx_drone_status_takeoff', 'drone_requests', ['status', 'estimated_takeoff_at'], unique=False)
def downgrade() -> None:
op.drop_index('idx_drone_status_takeoff', table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_submitted_at'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_status'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_reference_number'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_public_token'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_operator_name'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_flight_date'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_estimated_takeoff_at'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_estimated_completion_at'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_email'), table_name='drone_requests')
op.drop_index(op.f('ix_drone_requests_created_by'), table_name='drone_requests')
op.drop_table('drone_requests')
+2 -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, public_book, movements
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
api_router = APIRouter()
@@ -12,6 +12,7 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
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"])
+279
View File
@@ -0,0 +1,279 @@
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_operator_user, get_current_read_user, get_db
from app.core.email import email_service
from app.core.config import settings
from app.core.utils import get_client_ip
from app.crud.crud_drone_request import drone_request as crud_drone_request
from app.crud.crud_journal import journal as crud_journal
from app.models.journal import EntityType
from app.models.ppr import User
from app.schemas.drone_request import (
DroneRequest,
DroneRequestComment,
DroneRequestCreate,
DroneRequestStatus,
DroneRequestStatusUpdate,
DroneRequestUpdate,
)
from app.schemas.journal import JournalEntryResponse
router = APIRouter()
async def _broadcast(request: Request, event_type: str, drone_request: DroneRequest):
if hasattr(request.app.state, "connection_manager"):
await request.app.state.connection_manager.broadcast({
"type": event_type,
"data": {
"id": drone_request.id,
"reference_number": drone_request.reference_number,
"status": drone_request.status.value,
},
})
async def _send_drone_email(drone_request, subject: str, message: str):
await email_service.send_email(
to_email=drone_request.email,
subject=subject,
template_name="drone_request_update.html",
template_vars={
"name": drone_request.operator_name,
"reference_number": drone_request.reference_number,
"status": drone_request.status.value,
"message": message,
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
},
)
@router.get("/", response_model=List[DroneRequest])
async def get_drone_requests(
skip: int = 0,
limit: int = 100,
status: Optional[DroneRequestStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
return crud_drone_request.get_multi(
db,
skip=skip,
limit=limit,
status=status,
date_from=date_from,
date_to=date_to,
)
@router.post("/public", response_model=DroneRequest)
async def create_public_drone_request(
request: Request,
drone_request_in: DroneRequestCreate,
db: Session = Depends(get_db),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.create(
db,
obj_in=drone_request_in,
created_by="public",
user_ip=client_ip,
submitted_via="PUBLIC",
)
await _broadcast(request, "drone_request_created", drone_request)
await _send_drone_email(
drone_request,
f"Drone flight request received {drone_request.reference_number}",
"We have received your drone flight request. We will email you when the approval status changes or if we need more information.",
)
return drone_request
@router.get("/public/edit/{token}", response_model=DroneRequest)
async def get_drone_request_for_edit(
token: str,
db: Session = Depends(get_db),
):
drone_request = crud_drone_request.get_by_public_token(db, token)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
return drone_request
@router.patch("/public/edit/{token}", response_model=DroneRequest)
async def update_drone_request_public(
token: str,
drone_request_in: DroneRequestUpdate,
request: Request,
db: Session = Depends(get_db),
):
drone_request = crud_drone_request.get_by_public_token(db, token)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be edited while {drone_request.status.value}",
)
client_ip = get_client_ip(request)
updated_request = crud_drone_request.update(
db,
db_obj=drone_request,
obj_in=drone_request_in,
user="public",
user_ip=client_ip,
)
await _broadcast(request, "drone_request_updated", updated_request)
return updated_request
@router.delete("/public/cancel/{token}", response_model=DroneRequest)
async def cancel_drone_request_public(
token: str,
request: Request,
db: Session = Depends(get_db),
):
drone_request = crud_drone_request.get_by_public_token(db, token)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
)
client_ip = get_client_ip(request)
cancelled_request = crud_drone_request.update_status(
db,
request_id=drone_request.id,
status=DroneRequestStatus.CANCELED,
comment="Cancelled by operator using secure link",
user="public",
user_ip=client_ip,
)
await _broadcast(request, "drone_request_status_update", cancelled_request)
await _send_drone_email(
cancelled_request,
f"Drone request {cancelled_request.reference_number} CANCELED",
"Your drone flight request has been cancelled.",
)
return cancelled_request
@router.get("/{request_id}", response_model=DroneRequest)
async def get_drone_request(
request_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
drone_request = crud_drone_request.get(db, request_id)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
return drone_request
@router.patch("/{request_id}", response_model=DroneRequest)
async def update_drone_request(
request: Request,
request_id: int,
drone_request_in: DroneRequestUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
db_request = crud_drone_request.get(db, request_id)
if not db_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
client_ip = get_client_ip(request)
drone_request = crud_drone_request.update(
db,
db_obj=db_request,
obj_in=drone_request_in,
user=current_user.username,
user_ip=client_ip,
)
await _broadcast(request, "drone_request_updated", drone_request)
return drone_request
@router.patch("/{request_id}/status", response_model=DroneRequest)
async def update_drone_request_status(
request: Request,
request_id: int,
status_update: DroneRequestStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.update_status(
db,
request_id=request_id,
status=status_update.status,
comment=status_update.comment,
user=current_user.username,
user_ip=client_ip,
)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
await _broadcast(request, "drone_request_status_update", drone_request)
message = status_update.comment or f"Your drone flight request status is now {drone_request.status.value}."
await _send_drone_email(
drone_request,
f"Drone request {drone_request.reference_number} {drone_request.status.value}",
message,
)
return drone_request
@router.post("/{request_id}/comments", response_model=DroneRequest)
async def add_drone_request_comment(
request: Request,
request_id: int,
comment_in: DroneRequestComment,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.add_comment(
db,
request_id=request_id,
comment=comment_in.comment,
user=current_user.username,
user_ip=client_ip,
)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
await _broadcast(request, "drone_request_comment_added", drone_request)
if comment_in.email_applicant:
await _send_drone_email(
drone_request,
f"Drone request {drone_request.reference_number} update",
comment_in.comment,
)
return drone_request
@router.get("/{request_id}/journal", response_model=List[JournalEntryResponse])
async def get_drone_request_journal(
request_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
drone_request = crud_drone_request.get(db, request_id)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
return crud_journal.get_entity_journal(db, EntityType.DRONE_REQUEST, request_id)
+604 -5
View File
@@ -1,12 +1,22 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user
from sqlalchemy import func, or_
from datetime import date, datetime, time
from app.api.deps import get_db, get_current_operator_user, get_current_read_user
from app.crud.crud_movement import movement as crud_movement
from app.schemas.movement import Movement
from app.crud.crud_journal import journal as crud_journal
from app.schemas.movement import BulkMovementContext, BulkMovementLog, BulkMovementResult, Movement, MovementCreate
from app.models.ppr import User
from app.models.movement import MovementType
from app.models.arrival import Arrival, ArrivalStatus, SubmissionSource as ArrivalSubmissionSource
from app.models.circuit import Circuit
from app.models.departure import Departure, DepartureStatus, SubmissionSource as DepartureSubmissionSource
from app.models.journal import EntityType
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType, SubmissionSource as LocalSubmissionSource
from app.models.movement import Movement as MovementModel, MovementType
from app.models.overflight import Overflight, OverflightStatus
from app.models.ppr import PPRRecord, PPRStatus
from app.core.utils import get_client_ip
router = APIRouter()
@@ -32,6 +42,595 @@ async def get_movements(
return movements
def _clean_reg(registration: str) -> str:
return (registration or "").strip().upper()
def _clean_alnum(value: str) -> str:
return "".join(char for char in (value or "").upper() if char.isalnum())
def _sql_clean_alnum(column):
return func.upper(func.replace(func.replace(column, "-", ""), " ", ""))
def _combine_date_time(movement_date: date, movement_time: str) -> datetime:
if not movement_time:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A time is required for this strip"
)
try:
parsed_time = time.fromisoformat(movement_time)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="movement_time must be HH:MM or HH:MM:SS"
)
return datetime.combine(movement_date, parsed_time)
def _kind_to_movement_type(flight_kind: str) -> MovementType:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return MovementType.LANDING
if kind == "DEPARTURE":
return MovementType.TAKEOFF
if kind == "LOCAL":
return MovementType.TAKEOFF
if kind == "OVERFLIGHT":
return MovementType.OVERFLIGHT
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="flight_kind must be ARRIVAL, DEPARTURE, LOCAL or OVERFLIGHT"
)
def _strip_entity_type(flight_kind: str) -> Optional[str]:
kind = (flight_kind or "").strip().upper()
return {
"LOCAL": "LOCAL_FLIGHT",
"OVERFLIGHT": "OVERFLIGHT",
}.get(kind)
def _compatible_entity_types(flight_kind: str) -> List[str]:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return ["PPR", "ARRIVAL"]
if kind == "DEPARTURE":
return ["PPR", "DEPARTURE"]
if kind == "LOCAL":
return ["LOCAL_FLIGHT"]
if kind == "OVERFLIGHT":
return ["OVERFLIGHT"]
return []
def _ppr_to_dict(ppr: PPRRecord) -> dict:
return {
"id": ppr.id,
"status": ppr.status.value,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"eta": ppr.eta.isoformat() if ppr.eta else None,
"etd": ppr.etd.isoformat() if ppr.etd else None,
"pob_in": ppr.pob_in,
"pob_out": ppr.pob_out,
"notes": ppr.notes,
}
def _movement_to_dict(movement: MovementModel) -> dict:
return {
"id": movement.id,
"movement_type": movement.movement_type.value,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"timestamp": movement.timestamp.isoformat() if movement.timestamp else None,
"entity_type": movement.entity_type,
"entity_id": movement.entity_id,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
def _local_flight_to_dict(local: LocalFlight) -> dict:
return {
"id": local.id,
"aircraft_registration": local.registration,
"aircraft_type": local.type,
"callsign": local.callsign,
"pob": local.pob,
"flight_type": local.flight_type.value if local.flight_type else None,
"status": local.status.value if local.status else None,
"etd": local.etd.isoformat() if local.etd else None,
"takeoff_time": local.takeoff_dt.isoformat() if local.takeoff_dt else None,
"departed_time": local.departed_dt.isoformat() if local.departed_dt else None,
"landing_time": local.landed_dt.isoformat() if local.landed_dt else None,
"circuits": local.circuits,
"notes": local.notes,
}
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
if movements:
movement = movements[0]
return {
"source": "movement",
"movement_id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"movement_time": movement.timestamp.strftime("%H:%M") if movement.timestamp else None,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
if pprs:
ppr = pprs[0]
is_arrival = flight_kind.upper() == "ARRIVAL"
timestamp = ppr.eta if is_arrival else ppr.etd
return {
"source": "ppr",
"ppr_id": ppr.id,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"movement_time": timestamp.strftime("%H:%M") if timestamp else None,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"pob": ppr.pob_in if is_arrival else (ppr.pob_out or ppr.pob_in),
"notes": ppr.notes,
}
return {}
def _movement_for_entity(
db: Session,
entity_type: str,
entity_id: int,
movement_type: MovementType
) -> Optional[MovementModel]:
return db.query(MovementModel).filter(
MovementModel.entity_type == entity_type,
MovementModel.entity_id == entity_id,
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp).first()
def _create_or_update_movement(db: Session, movement_data: MovementCreate) -> MovementModel:
existing = _movement_for_entity(
db,
movement_data.entity_type,
movement_data.entity_id,
movement_data.movement_type
)
if existing:
return crud_movement.update(db, existing, movement_data)
return crud_movement.create(db, movement_data)
def _clear_local_circuit_details(
db: Session,
local_flight: LocalFlight
) -> None:
db.query(Circuit).filter(Circuit.local_flight_id == local_flight.id).delete()
db.query(MovementModel).filter(
MovementModel.entity_type == "LOCAL_FLIGHT",
MovementModel.entity_id == local_flight.id,
MovementModel.movement_type == MovementType.TOUCH_AND_GO
).delete()
db.commit()
@router.get("/bulk-context", response_model=BulkMovementContext)
async def get_bulk_movement_context(
target_date: date,
aircraft_registration: str,
flight_kind: str = "ARRIVAL",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Find same-day PPRs and movements that may match a bulk entry strip."""
clean_reg = _clean_reg(aircraft_registration)
clean_lookup = _clean_alnum(aircraft_registration)
movement_type = _kind_to_movement_type(flight_kind)
entity_type_filter = _strip_entity_type(flight_kind)
pprs = []
local_flights = []
if clean_lookup and flight_kind.upper() != "LOCAL":
pprs = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
or_(
func.date(PPRRecord.eta) == target_date,
func.date(PPRRecord.etd) == target_date
),
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).limit(10).all()
if clean_lookup and flight_kind.upper() == "LOCAL":
local_flights = db.query(LocalFlight).filter(
_sql_clean_alnum(LocalFlight.registration).like(f"{clean_lookup}%"),
or_(
func.date(LocalFlight.takeoff_dt) == target_date,
func.date(LocalFlight.departed_dt) == target_date,
func.date(LocalFlight.landed_dt) == target_date,
func.date(LocalFlight.etd) == target_date,
func.date(LocalFlight.created_dt) == target_date
),
LocalFlight.flight_type.in_([LocalFlightType.LOCAL, LocalFlightType.CIRCUITS]),
LocalFlight.status != LocalFlightStatus.CANCELLED
).order_by(LocalFlight.takeoff_dt, LocalFlight.etd, LocalFlight.created_dt).limit(10).all()
movements = []
if clean_lookup:
movements = db.query(MovementModel).filter(
func.date(MovementModel.timestamp) == target_date,
_sql_clean_alnum(MovementModel.aircraft_registration).like(f"{clean_lookup}%"),
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp.desc()).limit(10).all()
compatible_types = _compatible_entity_types(flight_kind)
if compatible_types:
movements = [movement for movement in movements if movement.entity_type in compatible_types]
return BulkMovementContext(
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
local_flights=[_local_flight_to_dict(local) for local in local_flights],
movements=[_movement_to_dict(movement) for movement in movements],
suggested=_build_suggestion(pprs, movements, flight_kind)
)
@router.post("/bulk-log", response_model=BulkMovementResult)
async def bulk_log_movement(
request: Request,
entry: BulkMovementLog,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create or update one same-day movement from a paper strip."""
client_ip = get_client_ip(request)
username = current_user.username
clean_reg = _clean_reg(entry.aircraft_registration)
clean_lookup = _clean_alnum(entry.aircraft_registration)
if not clean_reg:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aircraft registration is required")
movement_type = _kind_to_movement_type(entry.flight_kind)
flight_kind = entry.flight_kind.strip().upper()
primary_time = (
entry.landing_time if flight_kind == "ARRIVAL"
else entry.takeoff_time if flight_kind in ("DEPARTURE", "LOCAL")
else entry.contact_time if flight_kind == "OVERFLIGHT"
else entry.movement_time
) or entry.movement_time
timestamp = _combine_date_time(entry.movement_date, primary_time)
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
if not existing_movement and flight_kind != "LOCAL":
existing_movement = crud_movement.find_daily_match(
db,
entry.movement_date,
clean_reg,
movement_type,
entity_type=_strip_entity_type(flight_kind),
entity_types=None if _strip_entity_type(flight_kind) else _compatible_entity_types(flight_kind)
)
if existing_movement and existing_movement.entity_type not in _compatible_entity_types(flight_kind):
existing_movement = None
if flight_kind == "LOCAL":
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
if landing_dt < takeoff_dt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LOCAL landing time cannot be before takeoff time"
)
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
local = LocalFlight(
registration=clean_reg,
type=entry.aircraft_type or "",
callsign=entry.callsign,
pob=entry.pob,
flight_type=local_type,
status=LocalFlightStatus.LANDED,
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
circuits=entry.circuits or 0,
notes=entry.notes,
etd=takeoff_dt,
departed_dt=takeoff_dt,
takeoff_dt=takeoff_dt,
landed_dt=landing_dt,
created_by=username,
submitted_via=LocalSubmissionSource.ADMIN
)
db.add(local)
db.commit()
db.refresh(local)
action = "created"
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
takeoff_movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.TAKEOFF,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=takeoff_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_create_or_update_movement(db, MovementCreate(
movement_type=MovementType.LANDING,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=landing_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_clear_local_circuit_details(db, local)
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, f"Bulk local strip {action}: takeoff {takeoff_dt.strftime('%H:%M')}, landing {landing_dt.strftime('%H:%M')}, circuits {entry.circuits or 0}", username, client_ip)
return BulkMovementResult(action=action, movement=takeoff_movement, entity_type="LOCAL_FLIGHT", entity_id=local.id, message=f"Local strip {action} for {clean_reg}")
if flight_kind == "OVERFLIGHT":
contact_dt = _combine_date_time(entry.movement_date, entry.contact_time)
qsy_dt = _combine_date_time(entry.movement_date, entry.qsy_time) if entry.qsy_time else None
overflight = None
if existing_movement and existing_movement.entity_type == "OVERFLIGHT":
overflight = db.query(Overflight).filter(Overflight.id == existing_movement.entity_id).first()
if not overflight:
overflight = db.query(Overflight).filter(
_sql_clean_alnum(Overflight.registration) == clean_lookup,
func.date(Overflight.call_dt) == entry.movement_date
).first()
if not overflight:
overflight = Overflight(
registration=clean_reg,
pob=entry.pob,
type=entry.aircraft_type,
departure_airfield=entry.from_location,
destination_airfield=entry.to_location,
status=OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE,
call_dt=contact_dt,
qsy_dt=qsy_dt,
notes=entry.notes,
created_by=username
)
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "created"
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, "Overflight strip created from bulk flight log", username, client_ip)
else:
overflight.registration = clean_reg
overflight.pob = entry.pob
overflight.type = entry.aircraft_type
overflight.departure_airfield = entry.from_location
overflight.destination_airfield = entry.to_location
overflight.status = OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE
overflight.call_dt = contact_dt
overflight.qsy_dt = qsy_dt
overflight.notes = entry.notes
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "updated"
movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.OVERFLIGHT,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=contact_dt,
entity_type="OVERFLIGHT",
entity_id=overflight.id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, f"Bulk overflight strip {action}: contact {contact_dt.strftime('%H:%M')}" + (f", QSY {qsy_dt.strftime('%H:%M')}" if qsy_dt else ""), username, client_ip)
return BulkMovementResult(action=action, movement=movement, entity_type="OVERFLIGHT", entity_id=overflight.id, message=f"Overflight strip {action} for {clean_reg}")
ppr = None
if entry.ppr_id:
ppr = db.query(PPRRecord).filter(PPRRecord.id == entry.ppr_id).first()
if not ppr and existing_movement and existing_movement.entity_type == "PPR":
ppr = db.query(PPRRecord).filter(PPRRecord.id == existing_movement.entity_id).first()
if not ppr:
ppr = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg) == clean_lookup,
or_(
func.date(PPRRecord.eta) == entry.movement_date,
func.date(PPRRecord.etd) == entry.movement_date
),
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).first()
entity_type = existing_movement.entity_type if existing_movement else None
entity_id = existing_movement.entity_id if existing_movement else None
if not entity_type:
if ppr:
entity_type = "PPR"
entity_id = ppr.id
elif movement_type == MovementType.LANDING:
arrival = Arrival(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
in_from=entry.from_location or "ZZZZ",
status=ArrivalStatus.LANDED,
notes=entry.notes,
eta=timestamp,
landed_dt=timestamp,
created_by=username,
submitted_via=ArrivalSubmissionSource.ADMIN
)
db.add(arrival)
db.commit()
db.refresh(arrival)
entity_type = "ARRIVAL"
entity_id = arrival.id
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, "Arrival created from bulk flight log", username, client_ip)
else:
departure = Departure(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
out_to=entry.to_location or "ZZZZ",
status=DepartureStatus.DEPARTED,
notes=entry.notes,
etd=timestamp,
takeoff_dt=timestamp,
departed_dt=timestamp,
created_by=username,
submitted_via=DepartureSubmissionSource.ADMIN
)
db.add(departure)
db.commit()
db.refresh(departure)
entity_type = "DEPARTURE"
entity_id = departure.id
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, "Departure created from bulk flight log", username, client_ip)
movement_data = MovementCreate(
movement_type=movement_type,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=timestamp,
entity_type=entity_type,
entity_id=entity_id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
)
if existing_movement:
movement = crud_movement.update(db, existing_movement, movement_data)
action = "updated"
else:
movement = crud_movement.create(db, movement_data)
action = "created"
if entity_type == "PPR" and ppr:
ppr.ac_type = entry.aircraft_type or ppr.ac_type
ppr.ac_call = entry.callsign or ppr.ac_call
if movement_type == MovementType.LANDING:
ppr.in_from = entry.from_location or ppr.in_from
ppr.pob_in = entry.pob or ppr.pob_in
ppr.landed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED, PPRStatus.DEPARTED):
ppr.status = PPRStatus.LANDED
else:
ppr.out_to = entry.to_location or ppr.out_to
ppr.pob_out = entry.pob or ppr.pob_out
ppr.departed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
ppr.status = PPRStatus.DEPARTED
if entry.notes:
ppr.notes = entry.notes
db.add(ppr)
db.commit()
crud_journal.log_change(
db,
EntityType.PPR,
ppr.id,
f"Bulk flight log {action}: {movement_type.value} at {timestamp.strftime('%Y-%m-%d %H:%M')}",
username,
client_ip
)
elif entity_type == "ARRIVAL":
arrival = db.query(Arrival).filter(Arrival.id == entity_id).first()
if arrival:
arrival.registration = clean_reg
arrival.type = entry.aircraft_type
arrival.callsign = entry.callsign
arrival.pob = entry.pob or arrival.pob
arrival.in_from = entry.from_location or arrival.in_from
arrival.eta = timestamp
arrival.landed_dt = timestamp
arrival.status = ArrivalStatus.LANDED
arrival.notes = entry.notes
db.add(arrival)
db.commit()
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, f"Bulk flight log {action}: landing at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
elif entity_type == "DEPARTURE":
departure = db.query(Departure).filter(Departure.id == entity_id).first()
if departure:
departure.registration = clean_reg
departure.type = entry.aircraft_type
departure.callsign = entry.callsign
departure.pob = entry.pob or departure.pob
departure.out_to = entry.to_location or departure.out_to
departure.etd = timestamp
departure.takeoff_dt = timestamp
departure.departed_dt = timestamp
departure.status = DepartureStatus.DEPARTED
departure.notes = entry.notes
db.add(departure)
db.commit()
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, f"Bulk flight log {action}: takeoff at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "movement_bulk_logged",
"data": {
"id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"movement_type": movement.movement_type.value,
"action": action
}
})
return BulkMovementResult(
action=action,
movement=movement,
entity_type=entity_type,
entity_id=entity_id,
message=f"Movement {action} for {clean_reg}"
)
@router.get("/{movement_id}", response_model=Movement)
async def get_movement(
movement_id: int,
+35
View File
@@ -239,6 +239,41 @@ async def update_ppr_status(
return ppr
@router.post("/{ppr_id}/acknowledge", response_model=PPR)
async def acknowledge_ppr_strip(
request: Request,
ppr_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Acknowledge that the operator has created the paper strip for a PPR."""
client_ip = get_client_ip(request)
ppr = crud_ppr.acknowledge_strip(
db,
ppr_id=ppr_id,
user=current_user.username,
user_ip=client_ip
)
if not ppr:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="PPR record not found"
)
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "ppr_acknowledged",
"data": {
"id": ppr.id,
"ac_reg": ppr.ac_reg,
"acknowledged_dt": ppr.acknowledged_dt.isoformat() if ppr.acknowledged_dt else None,
"acknowledged_by": ppr.acknowledged_by
}
})
return ppr
@router.delete("/{ppr_id}", response_model=PPR)
async def delete_ppr(
request: Request,
+178
View File
@@ -0,0 +1,178 @@
from datetime import date, datetime
import secrets
from typing import List, Optional
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.crud.crud_journal import journal as crud_journal
from app.models.drone_request import DroneRequest, DroneRequestStatus
from app.models.journal import EntityType
from app.schemas.drone_request import DroneRequestCreate, DroneRequestUpdate
class CRUDDroneRequest:
def get(self, db: Session, request_id: int) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.id == request_id).first()
def get_by_reference(self, db: Session, reference_number: str) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.reference_number == reference_number).first()
def get_by_public_token(self, db: Session, token: str) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.public_token == token).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[DroneRequestStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
) -> List[DroneRequest]:
query = db.query(DroneRequest)
if status:
query = query.filter(DroneRequest.status == status)
if date_from:
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) >= date_from)
if date_to:
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) <= date_to)
return query.order_by(desc(DroneRequest.submitted_at)).offset(skip).limit(limit).all()
def create(
self,
db: Session,
obj_in: DroneRequestCreate,
created_by: str = "public",
user_ip: str = "127.0.0.1",
submitted_via: str = "PUBLIC",
) -> DroneRequest:
reference_number = self._generate_reference(db)
payload = obj_in.dict()
notes = payload.pop("notes", None)
db_obj = DroneRequest(
**payload,
applicant_notes=notes,
reference_number=reference_number,
public_token=secrets.token_urlsafe(64),
status=DroneRequestStatus.NEW,
created_by=created_by,
submitted_ip=user_ip,
submitted_via=submitted_via,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_change(
db,
EntityType.DRONE_REQUEST,
db_obj.id,
f"Drone request {db_obj.reference_number} created",
created_by,
user_ip,
)
return db_obj
def update(
self,
db: Session,
db_obj: DroneRequest,
obj_in: DroneRequestUpdate,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> DroneRequest:
update_data = obj_in.dict(exclude_unset=True)
if "notes" in update_data:
update_data["applicant_notes"] = update_data.pop("notes")
changes = []
for field, value in update_data.items():
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)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
for change in changes:
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, change, user, user_ip)
return db_obj
def update_status(
self,
db: Session,
request_id: int,
status: DroneRequestStatus,
comment: Optional[str] = None,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> Optional[DroneRequest]:
db_obj = self.get(db, request_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
db_obj.status_changed_at = datetime.utcnow()
db_obj.status_changed_by = user
if comment:
db_obj.operator_comments = comment
db.add(db_obj)
db.commit()
db.refresh(db_obj)
entry = f"Status changed from {old_status.value} to {status.value}"
if comment:
entry = f"{entry}: {comment}"
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, entry, user, user_ip)
return db_obj
def add_comment(
self,
db: Session,
request_id: int,
comment: str,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> Optional[DroneRequest]:
db_obj = self.get(db, request_id)
if not db_obj:
return None
db_obj.operator_comments = comment
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, f"Comment added: {comment}", user, user_ip)
return db_obj
def _generate_reference(self, db: Session) -> str:
prefix = f"DRN-{datetime.utcnow().strftime('%y%m%d')}"
references = db.query(DroneRequest.reference_number).filter(
DroneRequest.reference_number.like(f"{prefix}-%")
).all()
highest_sequence = 0
for (reference_number,) in references:
suffix = reference_number.rsplit("-", 1)[-1]
if suffix.isdigit():
highest_sequence = max(highest_sequence, int(suffix))
for sequence in range(highest_sequence + 1, highest_sequence + 11):
candidate = f"{prefix}-{sequence}"
if not self.get_by_reference(db, candidate):
return candidate
return f"{prefix}-{highest_sequence + 11}"
drone_request = CRUDDroneRequest()
+31
View File
@@ -47,6 +47,37 @@ class CRUDMovement:
db.refresh(db_obj)
return db_obj
def update(self, db: Session, db_obj: Movement, obj_in: MovementCreate) -> Movement:
update_data = obj_in.dict()
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def find_daily_match(
self,
db: Session,
target_date: date,
aircraft_registration: str,
movement_type: MovementType,
entity_type: Optional[str] = None,
entity_types: Optional[List[str]] = None
) -> Optional[Movement]:
clean_reg = "".join(char for char in aircraft_registration.upper() if char.isalnum())
clean_column = func.upper(func.replace(func.replace(Movement.aircraft_registration, "-", ""), " ", ""))
query = db.query(Movement).filter(
func.date(Movement.timestamp) == target_date,
clean_column == clean_reg,
Movement.movement_type == movement_type
)
if entity_type:
query = query.filter(Movement.entity_type == entity_type)
if entity_types:
query = query.filter(Movement.entity_type.in_(entity_types))
return query.order_by(Movement.timestamp.desc()).first()
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)
+30
View File
@@ -169,6 +169,36 @@ class CRUDPPR:
return db_obj
def acknowledge_strip(
self,
db: Session,
ppr_id: int,
user: str = "system",
user_ip: str = "127.0.0.1"
) -> Optional[PPRRecord]:
db_obj = self.get(db, ppr_id)
if not db_obj:
return None
if db_obj.acknowledged_dt:
return db_obj
db_obj.acknowledged_dt = datetime.utcnow()
db_obj.acknowledged_by = user
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_ppr_change(
db,
db_obj.id,
f"Paper strip acknowledged by {user}",
user,
user_ip
)
return db_obj
def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]:
db_obj = self.get(db, ppr_id)
if db_obj:
+1
View File
@@ -16,6 +16,7 @@ from app.models.departure import Departure
from app.models.arrival import Arrival
from app.models.circuit import Circuit
from app.models.movement import Movement
from app.models.drone_request import DroneRequest
# Set up logging
logging.basicConfig(level=logging.INFO)
+64
View File
@@ -0,0 +1,64 @@
from enum import Enum
from sqlalchemy import BigInteger, Boolean, Column, Date, DateTime, Enum as SQLEnum, Float, Index, Integer, JSON, String, Text
from sqlalchemy.sql import func
from app.db.session import Base
class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
class DroneRequest(Base):
__tablename__ = "drone_requests"
id = Column(BigInteger, primary_key=True, autoincrement=True)
reference_number = Column(String(24), nullable=False, unique=True, index=True)
public_token = Column(String(128), nullable=True, unique=True, index=True)
status = Column(SQLEnum(DroneRequestStatus), nullable=False, default=DroneRequestStatus.NEW, index=True)
operator_name = Column(String(128), nullable=False, index=True)
operator_id = Column(String(64), nullable=True)
flyer_name = Column(String(128), nullable=True)
flyer_id = Column(String(64), nullable=True)
email = Column(String(128), nullable=False, index=True)
phone = Column(String(32), nullable=True)
flight_date = Column(Date, nullable=True, index=True)
estimated_takeoff_time = Column(String(8), nullable=True)
estimated_completion_time = Column(String(8), nullable=True)
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
estimated_completion_at = Column(DateTime, nullable=False, index=True)
maximum_elevation_ft_amsl = Column(Integer, nullable=False)
location_description = Column(Text, nullable=True)
location_latitude = Column(Float, nullable=False)
location_longitude = Column(Float, nullable=False)
location_inside_frz = Column(Boolean, nullable=True)
prototype_overlay = Column(JSON, nullable=True)
applicant_notes = Column(Text, nullable=True)
operator_comments = Column(Text, nullable=True)
submitted_via = Column(String(32), nullable=False, default="PUBLIC")
submitted_ip = Column(String(45), nullable=True)
created_by = Column(String(50), nullable=True, index=True)
submitted_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
status_changed_at = Column(DateTime, nullable=True)
status_changed_by = Column(String(50), nullable=True)
__table_args__ = (
Index("idx_drone_status_takeoff", "status", "estimated_takeoff_at"),
)
@property
def notes(self):
return self.applicant_notes
+1
View File
@@ -7,6 +7,7 @@ from app.db.session import Base
class EntityType(str, PyEnum):
"""Entity types that can have journal entries"""
PPR = "PPR"
DRONE_REQUEST = "DRONE_REQUEST"
LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
+1 -1
View File
@@ -35,7 +35,7 @@ class LocalFlight(Base):
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=False) # Aircraft type
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False) # Persons on board
pob = Column(Integer, nullable=True) # Persons on board may be unknown for post-event logging
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes
+2
View File
@@ -43,6 +43,8 @@ class PPRRecord(Base):
departed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
acknowledged_dt = Column(DateTime, nullable=True)
acknowledged_by = Column(String(50), nullable=True)
public_token = Column(String(128), nullable=True, unique=True, index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
+106
View File
@@ -0,0 +1,106 @@
from datetime import date, datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, EmailStr, Field, validator
class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
class DroneRequestBase(BaseModel):
operator_name: str = Field(..., max_length=128)
operator_id: Optional[str] = Field(None, max_length=64)
flight_date: Optional[date] = None
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
estimated_completion_time: Optional[str] = Field(None, max_length=8)
maximum_elevation_ft_amsl: int = Field(..., ge=0)
location_description: Optional[str] = None
location_latitude: float = Field(..., ge=-90, le=90)
location_longitude: float = Field(..., ge=-180, le=180)
location_inside_frz: Optional[bool] = None
flyer_name: Optional[str] = Field(None, max_length=128)
flyer_id: Optional[str] = Field(None, max_length=64)
email: EmailStr
phone: Optional[str] = Field(None, max_length=32)
notes: Optional[str] = None
estimated_takeoff_at: datetime
estimated_completion_at: datetime
prototype_overlay: Optional[dict[str, Any]] = None
@validator("operator_name")
def validate_operator_name(cls, value):
value = value.strip()
if not value:
raise ValueError("Operator name is required")
return value
@validator("location_inside_frz", pre=True)
def parse_inside_frz(cls, value):
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"yes", "true", "1", "y"}:
return True
if normalized in {"no", "false", "0", "n"}:
return False
return value
class DroneRequestCreate(DroneRequestBase):
pass
class DroneRequestUpdate(BaseModel):
operator_name: Optional[str] = Field(None, max_length=128)
operator_id: Optional[str] = Field(None, max_length=64)
flyer_name: Optional[str] = Field(None, max_length=128)
flyer_id: Optional[str] = Field(None, max_length=64)
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=32)
flight_date: Optional[date] = None
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
estimated_completion_time: Optional[str] = Field(None, max_length=8)
estimated_takeoff_at: Optional[datetime] = None
estimated_completion_at: Optional[datetime] = None
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
location_description: Optional[str] = None
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
location_inside_frz: Optional[bool] = None
notes: Optional[str] = None
prototype_overlay: Optional[dict[str, Any]] = None
operator_comments: Optional[str] = None
class DroneRequestStatusUpdate(BaseModel):
status: DroneRequestStatus
comment: Optional[str] = None
class DroneRequestComment(BaseModel):
comment: str = Field(..., min_length=1)
email_applicant: bool = True
class DroneRequest(DroneRequestBase):
id: int
reference_number: str
status: DroneRequestStatus
operator_comments: Optional[str] = None
submitted_via: str
submitted_ip: Optional[str] = None
created_by: Optional[str] = None
submitted_at: datetime
updated_at: datetime
status_changed_at: Optional[datetime] = None
status_changed_by: Optional[str] = None
class Config:
from_attributes = True
+1 -1
View File
@@ -32,7 +32,7 @@ class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None
pob: int
pob: Optional[int] = None
flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure
+42 -3
View File
@@ -1,6 +1,6 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from datetime import date, datetime
from app.models.movement import MovementType
@@ -26,9 +26,48 @@ class MovementCreate(MovementBase):
pass
class BulkMovementLog(BaseModel):
flight_kind: str
movement_date: date
movement_time: Optional[str] = None
takeoff_time: Optional[str] = None
landing_time: Optional[str] = None
contact_time: Optional[str] = None
qsy_time: Optional[str] = None
aircraft_registration: str
aircraft_type: Optional[str] = None
callsign: Optional[str] = None
from_location: Optional[str] = None
to_location: Optional[str] = None
pob: Optional[int] = None
local_nature: Optional[str] = None
circuits: Optional[int] = None
runway: Optional[str] = None
wind: Optional[str] = None
pressure_setting: Optional[str] = None
notes: Optional[str] = None
ppr_id: Optional[int] = None
movement_id: Optional[int] = None
class BulkMovementContext(BaseModel):
pprs: List[dict]
local_flights: List[dict] = Field(default_factory=list)
movements: List[dict]
suggested: dict
class Movement(MovementBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class BulkMovementResult(BaseModel):
action: str
movement: Movement
entity_type: str
entity_id: int
message: str
+2
View File
@@ -88,6 +88,8 @@ class PPRInDBBase(PPRBase):
departed_dt: Optional[datetime] = None
created_by: Optional[str] = None
submitted_dt: datetime
acknowledged_dt: Optional[datetime] = None
acknowledged_by: Optional[str] = None
class Config:
from_attributes = True
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Drone Flight Request Update</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
<h2>Drone Flight Request Update</h2>
<p>Hello {{ name }},</p>
<p>{{ message }}</p>
<table cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr><td><strong>Reference</strong></td><td>{{ reference_number }}</td></tr>
<tr><td><strong>Status</strong></td><td>{{ status }}</td></tr>
<tr><td><strong>Takeoff</strong></td><td>{{ takeoff_time }}</td></tr>
<tr><td><strong>Completion</strong></td><td>{{ completion_time }}</td></tr>
<tr><td><strong>Location</strong></td><td>{{ location }}</td></tr>
<tr><td><strong>Max elevation</strong></td><td>{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
</table>
{% if edit_url %}
<p>You can <a href="{{ edit_url }}">view, update, or cancel your drone request</a> using this secure link.</p>
{% endif %}
<p>Please quote your reference number in any replies.</p>
</body>
</html>
+2
View File
@@ -41,6 +41,8 @@ CREATE TABLE submitted (
departed_dt DATETIME DEFAULT NULL,
created_by VARCHAR(16) DEFAULT NULL,
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
acknowledged_dt DATETIME DEFAULT NULL,
acknowledged_by VARCHAR(50) DEFAULT NULL,
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+24
View File
@@ -160,6 +160,15 @@ body {
background-color: #e67e22;
}
.btn-ack {
background-color: #8e44ad;
color: white;
}
.btn-ack:hover {
background-color: #71368a;
}
.btn-info {
background-color: #3498db;
color: white;
@@ -330,6 +339,21 @@ tbody tr:hover {
background-color: #f8f9fa;
}
tbody tr.ppr-strip-unacknowledged {
background-color: #fff0c2;
box-shadow: inset 4px 0 0 #f39c12;
}
tbody tr.ppr-strip-unacknowledged:hover {
background-color: #ffe6a1;
}
.ack-complete {
color: #1e7e34;
font-size: 0.75rem;
font-weight: 700;
}
.status {
display: inline-block;
padding: 0.3rem 0.6rem;
+42
View File
@@ -32,6 +32,8 @@
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/movements'">📈 Movements</a>
<a href="#" onclick="window.location.href = '/drone-requests'">Drone Requests</a>
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
@@ -260,6 +262,7 @@
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ACK</th>
</tr>
</thead>
<tbody id="upcoming-table-body">
@@ -822,6 +825,9 @@
const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important;';
if (pprNeedsStripAck(ppr)) {
row.classList.add('ppr-strip-unacknowledged');
}
// Format date as Day DD/MM (e.g., Wed 11/12)
const etaDate = new Date(ppr.eta);
@@ -835,6 +841,9 @@
<span class="notes-indicator">📝</span>
<span class="tooltip-text">${ppr.notes}</span>
</span>` : '';
const ackButton = pprNeedsStripAck(ppr)
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${ppr.id}, ${JSON.stringify(ppr.ac_reg || 'PPR')})' title="Acknowledge paper strip created">ACK</button>`
: '<span class="ack-complete" title="Paper strip acknowledged">ACK</span>';
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
@@ -844,6 +853,7 @@
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ackButton}</td>
`;
tbody.appendChild(row);
}
@@ -917,6 +927,9 @@
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isBookedIn = flight.isBookedIn;
if (!isLocal && !isBookedIn && pprNeedsStripAck(flight)) {
row.classList.add('ppr-strip-unacknowledged');
}
// Click handler that routes to correct modal
row.onclick = () => {
@@ -1066,7 +1079,13 @@
eta = formatTimeOnly(flight.eta);
pob = flight.pob_in;
fuel = flight.fuel || '-';
const ackButton = pprNeedsStripAck(flight)
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${flight.id}, ${JSON.stringify(flight.ac_reg || 'PPR')})' title="Acknowledge paper strip created">
ACK
</button>`
: '';
actionButtons = `
${ackButton}
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); activatePPR(${flight.id}, '${flight.ac_reg}', ${flight.out_to ? 'true' : 'false'})" title="Activate PPR - create arrival/departure records">
ACTIVATE
</button>
@@ -1092,6 +1111,29 @@
setupTooltips();
}
function pprNeedsStripAck(ppr) {
return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt;
}
async function acknowledgePPRStrip(pprId, acReg) {
if (!confirm(`Confirm paper strip has been created for ${acReg}?`)) return;
try {
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/acknowledge`, { method: 'POST' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.detail || 'Failed to acknowledge PPR strip', true);
return;
}
showNotification('PPR strip acknowledged');
await loadPPRs();
} catch (error) {
console.error('Error acknowledging PPR strip:', error);
showNotification('Error acknowledging PPR strip', true);
}
}
async function activatePPR(pprId, acReg, hasDeparture) {
const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.`
+ (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : '');
+1
View File
@@ -233,6 +233,7 @@
<div class="dropdown-menu" id="adminDropdownMenu">
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
</div>
+1050
View File
File diff suppressed because it is too large Load Diff
+334
View File
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drone Flight Request</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #f5f5f5;
color: #263645;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.5;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin: 2rem auto;
max-width: 900px;
padding: 2rem;
}
.header {
border-bottom: 2px solid #3498db;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
}
.header h1 { color: #2c3e50; margin-bottom: 0.4rem; }
.status {
border-radius: 999px;
color: white;
display: inline-flex;
font-size: 0.8rem;
font-weight: 700;
margin-top: 0.5rem;
padding: 0.3rem 0.7rem;
}
.status-NEW { background: #3498db; }
.status-APPROVED { background: #27ae60; }
.status-DENIED { background: #c0392b; }
.status-PENDING { background: #f39c12; }
.status-CANCELED { background: #7f8c8d; }
.status-INFLIGHT { background: #8e44ad; }
.status-COMPLETED { background: #2c3e50; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.full { grid-column: 1 / -1; }
label {
color: #555;
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
input, textarea {
border: 1px solid #d6dce2;
border-radius: 5px;
font: inherit;
padding: 0.65rem;
width: 100%;
}
textarea { min-height: 110px; resize: vertical; }
.actions {
border-top: 1px solid #e6e9ec;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
}
.btn {
border: 0;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.8rem 1.4rem;
}
.btn-primary { background: #3498db; color: white; }
.btn-danger { background: #e74c3c; color: white; }
.btn-secondary { background: #95a5a6; color: white; }
.message {
border-radius: 5px;
display: none;
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
.message.ok { background: #d4edda; color: #155724; display: block; }
.message.error { background: #f8d7da; color: #721c24; display: block; }
.read-only {
background: #eef1f4;
border-radius: 5px;
color: #607080;
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
@media (max-width: 720px) {
.container { margin: 0; min-height: 100vh; border-radius: 0; }
.grid { grid-template-columns: 1fr; }
.full { grid-column: auto; }
}
</style>
</head>
<body>
<main class="container">
<div class="header">
<h1>Drone Flight Request</h1>
<p id="summary">Loading request...</p>
<span id="status" class="status status-NEW" style="display: none;"></span>
</div>
<div id="message" class="message"></div>
<div id="locked" class="read-only" style="display: none;">This request can be viewed, but can no longer be edited or cancelled.</div>
<form id="request-form" style="display: none;">
<div class="grid">
<div>
<label for="operator_name">Operator name</label>
<input id="operator_name" required>
</div>
<div>
<label for="operator_id">Operator ID</label>
<input id="operator_id">
</div>
<div>
<label for="flyer_name">Flyer name</label>
<input id="flyer_name">
</div>
<div>
<label for="flyer_id">Flyer ID</label>
<input id="flyer_id">
</div>
<div>
<label for="email">Email</label>
<input id="email" type="email" required>
</div>
<div>
<label for="phone">Phone</label>
<input id="phone">
</div>
<div>
<label for="estimated_takeoff_at">Estimated takeoff</label>
<input id="estimated_takeoff_at" type="datetime-local" required>
</div>
<div>
<label for="estimated_completion_at">Estimated completion</label>
<input id="estimated_completion_at" type="datetime-local" required>
</div>
<div>
<label for="maximum_elevation_ft_amsl">Maximum elevation ft AMSL</label>
<input id="maximum_elevation_ft_amsl" type="number" min="0" required>
</div>
<div>
<label for="location_inside_frz">Inside FRZ</label>
<input id="location_inside_frz" readonly>
</div>
<div>
<label for="location_latitude">Latitude</label>
<input id="location_latitude" type="number" step="0.000001" required>
</div>
<div>
<label for="location_longitude">Longitude</label>
<input id="location_longitude" type="number" step="0.000001" required>
</div>
<div class="full">
<label for="location_description">Location description</label>
<input id="location_description">
</div>
<div class="full">
<label for="notes">Notes</label>
<textarea id="notes"></textarea>
</div>
<div class="full">
<label for="operator_comments">Airport comments</label>
<textarea id="operator_comments" readonly></textarea>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="save-btn" type="submit">Save Changes</button>
<button class="btn btn-danger" id="cancel-btn" type="button" onclick="cancelRequest()">Cancel Request</button>
<button class="btn btn-secondary" type="button" onclick="loadRequest()">Reload</button>
</div>
</form>
</main>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
let currentRequest = null;
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('request-form').addEventListener('submit', saveRequest);
if (!token) {
showMessage('Missing secure request token.', true);
return;
}
loadRequest();
});
async function loadRequest() {
try {
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`);
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Unable to load request');
currentRequest = data;
populateForm(data);
showMessage('', false, true);
} catch (err) {
showMessage(err.message, true);
}
}
function populateForm(request) {
document.getElementById('summary').textContent = `${request.reference_number} - ${request.operator_name}`;
const status = document.getElementById('status');
status.textContent = request.status;
status.className = `status status-${request.status}`;
status.style.display = 'inline-flex';
setValue('operator_name', request.operator_name);
setValue('operator_id', request.operator_id);
setValue('flyer_name', request.flyer_name);
setValue('flyer_id', request.flyer_id);
setValue('email', request.email);
setValue('phone', request.phone);
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
setValue('maximum_elevation_ft_amsl', request.maximum_elevation_ft_amsl);
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
setValue('location_latitude', request.location_latitude);
setValue('location_longitude', request.location_longitude);
setValue('location_description', request.location_description);
setValue('notes', request.notes);
setValue('operator_comments', request.operator_comments);
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status);
document.getElementById('locked').style.display = locked ? 'block' : 'none';
document.getElementById('save-btn').disabled = locked;
document.getElementById('cancel-btn').disabled = locked;
document.querySelectorAll('#request-form input, #request-form textarea').forEach(input => {
if (input.id !== 'operator_comments' && input.id !== 'location_inside_frz') {
input.readOnly = locked;
}
});
document.getElementById('request-form').style.display = 'block';
}
async function saveRequest(event) {
event.preventDefault();
const payload = {
operator_name: value('operator_name'),
operator_id: value('operator_id') || null,
flyer_name: value('flyer_name') || null,
flyer_id: value('flyer_id') || null,
email: value('email'),
phone: value('phone') || null,
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
maximum_elevation_ft_amsl: Number(value('maximum_elevation_ft_amsl')),
location_latitude: Number(value('location_latitude')),
location_longitude: Number(value('location_longitude')),
location_description: value('location_description') || null,
notes: value('notes') || null
};
try {
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Unable to save request');
currentRequest = data;
populateForm(data);
showMessage('Request updated.');
} catch (err) {
showMessage(err.message, true);
}
}
async function cancelRequest() {
if (!confirm('Cancel this drone flight request?')) return;
try {
const response = await fetch(`/api/v1/drone-requests/public/cancel/${encodeURIComponent(token)}`, {
method: 'DELETE'
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Unable to cancel request');
currentRequest = data;
populateForm(data);
showMessage('Request cancelled.');
} catch (err) {
showMessage(err.message, true);
}
}
function setValue(id, value) {
document.getElementById(id).value = value == null ? '' : value;
}
function value(id) {
return document.getElementById(id).value.trim();
}
function toLocalInputValue(value) {
if (!value) return '';
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
const date = new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`);
return date.toISOString().slice(0, 16);
}
function fromLocalInputValue(value) {
return new Date(value).toISOString();
}
function showMessage(message, isError = false, clear = false) {
const element = document.getElementById('message');
if (clear || !message) {
element.textContent = '';
element.className = 'message';
return;
}
element.textContent = message;
element.className = `message ${isError ? 'error' : 'ok'}`;
}
</script>
</body>
</html>
+690
View File
@@ -0,0 +1,690 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drone Flight Requests</title>
<link rel="stylesheet" href="admin.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.workspace {
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(520px, 1.35fr);
gap: 1rem;
align-items: start;
}
.toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.request-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.request-list-body {
max-height: calc(100vh - 210px);
overflow-y: auto;
}
.request-row {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
text-align: left;
padding: 0.85rem 1rem;
border: 0;
border-bottom: 1px solid #e6e9ec;
background: white;
cursor: pointer;
}
.request-row:hover,
.request-row.active {
background: #eef6fb;
}
.request-ref {
font-weight: 700;
color: #263645;
}
.request-meta,
.detail-meta {
color: #5d6d7e;
font-size: 0.88rem;
line-height: 1.45;
}
.status-pill {
align-self: start;
border-radius: 999px;
color: white;
display: inline-flex;
font-size: 0.75rem;
font-weight: 700;
justify-content: center;
min-width: 86px;
padding: 0.25rem 0.55rem;
}
.status-NEW { background: #3498db; }
.status-APPROVED { background: #27ae60; }
.status-DENIED { background: #c0392b; }
.status-PENDING { background: #f39c12; }
.status-CANCELED { background: #7f8c8d; }
.status-INFLIGHT { background: #8e44ad; }
.status-COMPLETED { background: #2c3e50; }
.detail-shell {
display: grid;
grid-template-rows: auto minmax(360px, 52vh) auto;
gap: 1rem;
}
.detail-panel,
.action-panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.detail-body,
.action-body {
padding: 1rem;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.field-label {
color: #607080;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.field-value {
color: #263645;
font-weight: 600;
margin-top: 0.15rem;
overflow-wrap: anywhere;
}
.map-panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
min-height: 360px;
}
#request-map {
width: 100%;
height: 100%;
min-height: 360px;
}
.action-row {
display: grid;
grid-template-columns: minmax(160px, 220px) 1fr auto;
gap: 0.75rem;
align-items: start;
}
textarea {
min-height: 86px;
resize: vertical;
}
select,
input,
textarea {
border: 1px solid #d6dce2;
border-radius: 5px;
font: inherit;
padding: 0.58rem 0.65rem;
width: 100%;
}
.journal {
margin-top: 1rem;
border-top: 1px solid #e6e9ec;
padding-top: 0.75rem;
}
.journal-entry {
border-bottom: 1px solid #eef1f4;
padding: 0.55rem 0;
}
.empty-state {
color: #607080;
padding: 2rem;
text-align: center;
}
.notification {
position: fixed;
right: 1rem;
top: 1rem;
transform: translateY(-120%);
transition: transform 0.2s ease;
z-index: 3000;
}
.notification.show {
transform: translateY(0);
}
@media (max-width: 980px) {
.workspace,
.detail-grid,
.action-row {
grid-template-columns: 1fr;
}
.request-list-body {
max-height: 360px;
}
}
</style>
</head>
<body>
<div class="top-bar">
<div class="title">
<h1 id="tower-title">Swansea Tower</h1>
</div>
<div class="menu-buttons">
<button class="btn btn-secondary" onclick="window.location.href = '/admin'">Tower</button>
<button class="btn btn-secondary" onclick="window.location.href = '/journal'">Journal</button>
</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">
<div class="toolbar">
<div>
<h2>Drone Flight Requests</h2>
<div class="detail-meta">Requests from the public drone flight form</div>
</div>
<div class="filter-row">
<select id="status-filter" aria-label="Status filter">
<option value="">All statuses</option>
<option value="NEW">New</option>
<option value="PENDING">Pending</option>
<option value="APPROVED">Approved</option>
<option value="DENIED">Denied</option>
<option value="CANCELED">Canceled</option>
<option value="INFLIGHT">Inflight</option>
<option value="COMPLETED">Completed</option>
</select>
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
</div>
</div>
<div class="workspace">
<section class="request-list">
<div class="table-header">Request Queue - <span id="request-count">0</span></div>
<div id="request-list-body" class="request-list-body">
<div class="empty-state">Loading requests...</div>
</div>
</section>
<section class="detail-shell">
<div class="detail-panel">
<div class="table-header">Selected Request</div>
<div id="detail-body" class="detail-body">
<div class="empty-state">Select a request to view its details.</div>
</div>
</div>
<div class="map-panel">
<div id="request-map"></div>
</div>
<div class="action-panel">
<div class="table-header">Lifecycle</div>
<div class="action-body">
<div class="action-row">
<select id="status-select" aria-label="New status">
<option value="NEW">NEW</option>
<option value="PENDING">PENDING</option>
<option value="APPROVED">APPROVED</option>
<option value="DENIED">DENIED</option>
<option value="CANCELED">CANCELED</option>
<option value="INFLIGHT">INFLIGHT</option>
<option value="COMPLETED">COMPLETED</option>
</select>
<textarea id="operator-comment" placeholder="Comment or request for more information"></textarea>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<button class="btn btn-success" onclick="saveStatus()">Set Status</button>
<button class="btn btn-info" onclick="sendComment()">Send Comment</button>
</div>
</div>
<div id="journal" class="journal"></div>
</div>
</div>
</section>
</div>
</div>
<div id="notification" class="notification btn"></div>
<div id="loginModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header">
<h2>Login</h2>
</div>
<form id="login-form">
<div class="form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input type="password" id="login-password" required>
</div>
<div id="login-error" class="error-message" style="display: none;"></div>
<div class="form-actions">
<button id="login-btn" class="btn btn-primary" type="submit">Login</button>
</div>
</form>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let accessToken = null;
let currentUser = null;
let selectedRequest = null;
let requests = [];
let map = null;
let mapLayers = [];
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('status-filter').addEventListener('change', loadRequests);
initializeAuth();
initializeMap();
connectWebSocket();
});
function initializeMap() {
map = L.map('request-map').setView([51.6053, -4.0678], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
}
function initializeAuth() {
const token = localStorage.getItem('ppr_access_token');
const username = localStorage.getItem('ppr_username');
const expiry = localStorage.getItem('ppr_token_expiry');
if (token && username && expiry && Date.now() < parseInt(expiry)) {
accessToken = token;
currentUser = username;
document.getElementById('current-user').textContent = username;
loadRequests();
return;
}
showLogin();
}
function showLogin() {
document.getElementById('loginModal').style.display = 'block';
document.getElementById('login-username').focus();
}
function hideLogin() {
document.getElementById('loginModal').style.display = 'none';
document.getElementById('login-error').style.display = 'none';
document.getElementById('login-form').reset();
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const error = document.getElementById('login-error');
const button = document.getElementById('login-btn');
button.disabled = true;
error.style.display = 'none';
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Login failed');
const expiry = Date.now() + ((data.expires_in || 1800) * 1000);
localStorage.setItem('ppr_access_token', data.access_token);
localStorage.setItem('ppr_username', username);
localStorage.setItem('ppr_token_expiry', expiry.toString());
accessToken = data.access_token;
currentUser = username;
document.getElementById('current-user').textContent = username;
hideLogin();
loadRequests();
} catch (err) {
error.textContent = err.message;
error.style.display = 'block';
} finally {
button.disabled = false;
}
}
function logout() {
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
accessToken = null;
currentUser = null;
selectedRequest = null;
showLogin();
}
async function authenticatedFetch(url, options = {}) {
if (!accessToken) {
showLogin();
throw new Error('No access token');
}
const response = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) {
logout();
throw new Error('Session expired');
}
return response;
}
async function loadRequests() {
if (!accessToken) return;
const status = document.getElementById('status-filter').value;
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
const body = document.getElementById('request-list-body');
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
try {
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error('Failed to load drone requests');
requests = await response.json();
renderRequestList();
if (selectedRequest) {
const fresh = requests.find(r => r.id === selectedRequest.id);
if (fresh) selectRequest(fresh.id);
}
} catch (err) {
body.innerHTML = `<div class="empty-state">${escapeHtml(err.message)}</div>`;
}
}
function renderRequestList() {
document.getElementById('request-count').textContent = requests.length;
const body = document.getElementById('request-list-body');
if (!requests.length) {
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
return;
}
body.innerHTML = requests.map(req => `
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
<div>
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
<div class="request-meta">${escapeHtml(req.operator_name)} · ${formatDateTime(req.estimated_takeoff_at)}</div>
<div class="request-meta">${escapeHtml(req.location_description || `${req.location_latitude}, ${req.location_longitude}`)}</div>
</div>
<span class="status-pill status-${req.status}">${req.status}</span>
</button>
`).join('');
}
async function selectRequest(id) {
selectedRequest = requests.find(req => req.id === id);
if (!selectedRequest) return;
renderRequestList();
renderDetails();
renderMap();
await loadJournal();
}
function renderDetails() {
document.getElementById('status-select').value = selectedRequest.status;
document.getElementById('operator-comment').value = selectedRequest.operator_comments || '';
document.getElementById('detail-body').innerHTML = `
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1rem;">
<div>
<h2>${escapeHtml(selectedRequest.reference_number)}</h2>
<div class="detail-meta">${escapeHtml(selectedRequest.operator_name)} · ${escapeHtml(selectedRequest.email)} · ${escapeHtml(selectedRequest.phone || '-')}</div>
</div>
<span class="status-pill status-${selectedRequest.status}">${selectedRequest.status}</span>
</div>
<div class="detail-grid">
${field('Operator ID', selectedRequest.operator_id)}
${field('Flyer', selectedRequest.flyer_name)}
${field('Flyer ID', selectedRequest.flyer_id)}
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
${field('Completion', formatDateTime(selectedRequest.estimated_completion_at))}
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_amsl} ft AMSL`)}
${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
${field('Latitude', selectedRequest.location_latitude)}
${field('Longitude', selectedRequest.location_longitude)}
${field('Location', selectedRequest.location_description || '-')}
${field('Applicant Notes', selectedRequest.notes || '-')}
${field('Operator Comments', selectedRequest.operator_comments || '-')}
</div>
`;
}
function field(label, value) {
return `<div><div class="field-label">${escapeHtml(label)}</div><div class="field-value">${escapeHtml(value == null ? '-' : String(value))}</div></div>`;
}
function renderMap() {
clearMapLayers();
if (!selectedRequest || !map) return;
const point = [selectedRequest.location_latitude, selectedRequest.location_longitude];
const overlay = selectedRequest.prototype_overlay || {};
const arp = overlay.airport_reference_point || { lat: 51.6053, lng: -4.0678 };
const radius = overlay.frz_radius_metres || 3704;
addLayer(L.circle([arp.lat, arp.lng], {
radius,
color: '#2c3e50',
weight: 2,
fillColor: '#3498db',
fillOpacity: 0.08
}).addTo(map));
addRunwayProtectionRectangles(arp);
addLayer(L.marker(point).addTo(map).bindPopup(`
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
${escapeHtml(selectedRequest.operator_name)}<br>
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL
`));
const group = L.featureGroup(mapLayers);
map.fitBounds(group.getBounds().pad(0.18));
setTimeout(() => map.invalidateSize(), 50);
}
function addRunwayProtectionRectangles(arp) {
addLayer(L.polygon(rotatedRectangle(arp.lat, arp.lng, 41, 5000, 1000), {
color: '#e67e22',
weight: 1,
fillOpacity: 0.08
}).addTo(map));
addLayer(L.polygon(rotatedRectangle(arp.lat, arp.lng, 95, 5000, 1000), {
color: '#e67e22',
weight: 1,
fillOpacity: 0.08
}).addTo(map));
}
function rotatedRectangle(lat, lng, bearingDeg, lengthM, widthM) {
const halfL = lengthM / 2;
const halfW = widthM / 2;
return [
offsetPoint(lat, lng, bearingDeg, halfL, halfW),
offsetPoint(lat, lng, bearingDeg, halfL, -halfW),
offsetPoint(lat, lng, bearingDeg, -halfL, -halfW),
offsetPoint(lat, lng, bearingDeg, -halfL, halfW)
];
}
function offsetPoint(lat, lng, bearingDeg, forwardM, rightM) {
const bearing = bearingDeg * Math.PI / 180;
const northM = Math.cos(bearing) * forwardM + Math.cos(bearing + Math.PI / 2) * rightM;
const eastM = Math.sin(bearing) * forwardM + Math.sin(bearing + Math.PI / 2) * rightM;
const latOffset = northM / 111320;
const lngOffset = eastM / (111320 * Math.cos(lat * Math.PI / 180));
return [lat + latOffset, lng + lngOffset];
}
function addLayer(layer) {
mapLayers.push(layer);
}
function clearMapLayers() {
mapLayers.forEach(layer => map.removeLayer(layer));
mapLayers = [];
}
async function saveStatus() {
if (!selectedRequest) return showNotification('Select a request first', true);
const status = document.getElementById('status-select').value;
const comment = document.getElementById('operator-comment').value.trim();
try {
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, comment: comment || null })
});
if (!response.ok) throw new Error('Failed to update status');
selectedRequest = await response.json();
showNotification('Status updated');
await loadRequests();
renderDetails();
await loadJournal();
} catch (err) {
showNotification(err.message, true);
}
}
async function sendComment() {
if (!selectedRequest) return showNotification('Select a request first', true);
const comment = document.getElementById('operator-comment').value.trim();
if (!comment) return showNotification('Enter a comment first', true);
try {
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment, email_applicant: true })
});
if (!response.ok) throw new Error('Failed to send comment');
selectedRequest = await response.json();
showNotification('Comment sent');
await loadRequests();
renderDetails();
await loadJournal();
} catch (err) {
showNotification(err.message, true);
}
}
async function loadJournal() {
const journal = document.getElementById('journal');
if (!selectedRequest) {
journal.innerHTML = '';
return;
}
try {
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/journal`);
if (!response.ok) throw new Error('Failed to load journal');
const entries = await response.json();
journal.innerHTML = entries.length ? entries.map(entry => `
<div class="journal-entry">
<div>${escapeHtml(entry.entry)}</div>
<div class="request-meta">${escapeHtml(entry.user)} · ${formatDateTime(entry.entry_dt)}</div>
</div>
`).join('') : '<div class="request-meta">No journal entries yet.</div>';
} catch (err) {
journal.innerHTML = `<div class="request-meta">${escapeHtml(err.message)}</div>`;
}
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/tower-updates`);
ws.onmessage = event => {
if (event.data.startsWith('Heartbeat:')) return;
try {
const data = JSON.parse(event.data);
if (data.type && data.type.startsWith('drone_request_')) loadRequests();
} catch (err) {
console.warn('WebSocket parse failed', err);
}
};
}
function formatDateTime(value) {
if (!value) return '-';
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
const date = new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`);
return date.toISOString().slice(0, 16).replace('T', ' ');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function showNotification(message, isError = false) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = `notification btn ${isError ? 'btn-danger' : 'btn-success'} show`;
setTimeout(() => notification.classList.remove('show'), 3000);
}
</script>
</body>
</html>
+19 -2
View File
@@ -168,7 +168,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Format the aircraft registration
const formatted = formatAircraftRegistration(searchTerm);
const field = document.getElementById(fieldId);
if (field) {
if (field && fieldId !== 'aircraft-registration') {
field.value = formatted;
// Mark the form for auto-saving this aircraft
const form = field.closest('form');
@@ -194,7 +194,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Auto-populate the form fields
const field = document.getElementById(fieldId);
if (field) {
if (field && fieldId !== 'aircraft-registration') {
field.value = aircraft.registration;
// Clear the unsaved aircraft flag since we found a match
const form = field.closest('form');
@@ -213,6 +213,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
typeFieldId = 'book_in_type';
} else if (fieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
} else if (fieldId === 'aircraft-registration') {
typeFieldId = 'aircraft-type';
}
if (typeFieldId) {
@@ -330,6 +332,8 @@ const lookupManager = {
}
};
window.lookupManager = lookupManager;
// Initialize all lookups when page loads
function initializeLookups() {
// Create reusable lookup instances
@@ -413,6 +417,14 @@ function initializeLookups() {
);
lookupManager.register('overflight-destination', overflightDestinationLookup);
const bulkAircraftLookup = createLookup(
'aircraft-registration',
'bulk-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('bulk-aircraft', bulkAircraftLookup);
// Attach keyboard handlers to airport input fields
setTimeout(() => {
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
@@ -459,6 +471,11 @@ function handleLocalAircraftLookup(value) {
if (lookup) lookup.handle(value);
}
function handleBulkAircraftLookup(value) {
const lookup = lookupManager.lookups['bulk-aircraft'];
if (lookup) lookup.handle(value);
}
function clearArrivalAirportLookup() {
const lookup = lookupManager.lookups['arrival-airport'];
if (lookup) lookup.clear();