Drone flights and Bulk Logging WIPs

This commit is contained in:
2026-06-19 17:27:33 -04:00
parent 1952b89ecf
commit 78d738b0ee
18 changed files with 2051 additions and 70 deletions
@@ -17,8 +17,11 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True)) 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.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: 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_by')
op.drop_column('submitted', 'acknowledged_dt') 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')
+3 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, 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() api_router = APIRouter()
@@ -12,7 +12,8 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"]) api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"]) api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(movements.router, prefix="/movements", tags=["movements"]) 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.router, prefix="/public", tags=["public"])
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"]) 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(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"]) api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
+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)
+64 -52
View File
@@ -143,6 +143,24 @@ def _movement_to_dict(movement: MovementModel) -> dict:
} }
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: def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
if movements: if movements:
movement = movements[0] movement = movements[0]
@@ -234,7 +252,8 @@ async def get_bulk_movement_context(
entity_type_filter = _strip_entity_type(flight_kind) entity_type_filter = _strip_entity_type(flight_kind)
pprs = [] pprs = []
if clean_lookup: local_flights = []
if clean_lookup and flight_kind.upper() != "LOCAL":
pprs = db.query(PPRRecord).filter( pprs = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"), _sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
or_( or_(
@@ -244,6 +263,20 @@ async def get_bulk_movement_context(
PPRRecord.status != PPRStatus.DELETED PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).limit(10).all() ).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 = [] movements = []
if clean_lookup: if clean_lookup:
movements = db.query(MovementModel).filter( movements = db.query(MovementModel).filter(
@@ -257,6 +290,7 @@ async def get_bulk_movement_context(
return BulkMovementContext( return BulkMovementContext(
pprs=[_ppr_to_dict(ppr) for ppr in pprs], 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], movements=[_movement_to_dict(movement) for movement in movements],
suggested=_build_suggestion(pprs, movements, flight_kind) suggested=_build_suggestion(pprs, movements, flight_kind)
) )
@@ -286,8 +320,8 @@ async def bulk_log_movement(
else entry.movement_time else entry.movement_time
) or entry.movement_time ) or entry.movement_time
timestamp = _combine_date_time(entry.movement_date, primary_time) timestamp = _combine_date_time(entry.movement_date, primary_time)
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id else None existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
if not existing_movement: if not existing_movement and flight_kind != "LOCAL":
existing_movement = crud_movement.find_daily_match( existing_movement = crud_movement.find_daily_match(
db, db,
entry.movement_date, entry.movement_date,
@@ -302,56 +336,34 @@ async def bulk_log_movement(
if flight_kind == "LOCAL": if flight_kind == "LOCAL":
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time) takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time) landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL if landing_dt < takeoff_dt:
local = None raise HTTPException(
if existing_movement and existing_movement.entity_type == "LOCAL_FLIGHT": status_code=status.HTTP_400_BAD_REQUEST,
local = db.query(LocalFlight).filter(LocalFlight.id == existing_movement.entity_id).first() detail="LOCAL landing time cannot be before takeoff time"
if not local:
local = db.query(LocalFlight).filter(
_sql_clean_alnum(LocalFlight.registration) == clean_lookup,
func.date(LocalFlight.takeoff_dt) == entry.movement_date
).first()
if not local:
local = LocalFlight(
registration=clean_reg,
type=entry.aircraft_type or "",
callsign=entry.callsign,
pob=entry.pob or 1,
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) local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
db.commit() local = LocalFlight(
db.refresh(local) registration=clean_reg,
action = "created" type=entry.aircraft_type or "",
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip) callsign=entry.callsign,
else: pob=entry.pob,
local.registration = clean_reg flight_type=local_type,
local.type = entry.aircraft_type or local.type status=LocalFlightStatus.LANDED,
local.callsign = entry.callsign duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
local.pob = entry.pob or local.pob circuits=entry.circuits or 0,
local.flight_type = local_type notes=entry.notes,
local.status = LocalFlightStatus.LANDED etd=takeoff_dt,
local.duration = int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else local.duration departed_dt=takeoff_dt,
local.circuits = entry.circuits or 0 takeoff_dt=takeoff_dt,
local.notes = entry.notes landed_dt=landing_dt,
local.etd = takeoff_dt created_by=username,
local.departed_dt = takeoff_dt submitted_via=LocalSubmissionSource.ADMIN
local.takeoff_dt = takeoff_dt )
local.landed_dt = landing_dt db.add(local)
db.add(local) db.commit()
db.commit() db.refresh(local)
db.refresh(local) action = "created"
action = "updated" 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( takeoff_movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.TAKEOFF, movement_type=MovementType.TAKEOFF,
+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()
+2 -1
View File
@@ -16,6 +16,7 @@ from app.models.departure import Departure
from app.models.arrival import Arrival from app.models.arrival import Arrival
from app.models.circuit import Circuit from app.models.circuit import Circuit
from app.models.movement import Movement from app.models.movement import Movement
from app.models.drone_request import DroneRequest
# Set up logging # Set up logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -206,4 +207,4 @@ async def health_check():
app.include_router(api_router, prefix=settings.api_v1_str) app.include_router(api_router, prefix=settings.api_v1_str)
# Make connection manager available to the app # Make connection manager available to the app
app.state.connection_manager = manager app.state.connection_manager = manager
+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): class EntityType(str, PyEnum):
"""Entity types that can have journal entries""" """Entity types that can have journal entries"""
PPR = "PPR" PPR = "PPR"
DRONE_REQUEST = "DRONE_REQUEST"
LOCAL_FLIGHT = "LOCAL_FLIGHT" LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL" ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE" DEPARTURE = "DEPARTURE"
+1 -1
View File
@@ -35,7 +35,7 @@ class LocalFlight(Base):
registration = Column(String(16), nullable=False, index=True) registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=False) # Aircraft type type = Column(String(32), nullable=False) # Aircraft type
callsign = Column(String(16), nullable=True) 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) flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True) status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes duration = Column(Integer, nullable=True) # Duration in minutes
+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
+2 -2
View File
@@ -32,7 +32,7 @@ class LocalFlightBase(BaseModel):
registration: str registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None callsign: Optional[str] = None
pob: int pob: Optional[int] = None
flight_type: LocalFlightType flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45 duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure etd: Optional[datetime] = None # Estimated Time of Departure
@@ -105,4 +105,4 @@ class LocalFlightInDBBase(LocalFlightBase):
class LocalFlight(LocalFlightInDBBase): class LocalFlight(LocalFlightInDBBase):
pass pass
+2 -1
View File
@@ -1,5 +1,5 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel, Field
from datetime import date, datetime from datetime import date, datetime
from app.models.movement import MovementType from app.models.movement import MovementType
@@ -52,6 +52,7 @@ class BulkMovementLog(BaseModel):
class BulkMovementContext(BaseModel): class BulkMovementContext(BaseModel):
pprs: List[dict] pprs: List[dict]
local_flights: List[dict] = Field(default_factory=list)
movements: List[dict] movements: List[dict]
suggested: dict suggested: dict
@@ -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>
+1
View File
@@ -32,6 +32,7 @@
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a> <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 = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/movements'">📈 Movements</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 = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal 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="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
+204 -11
View File
@@ -266,6 +266,85 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
.match-strips {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.55rem;
}
.match-strip-preview {
background: transparent;
border: 0;
display: block;
padding: 0;
text-align: left;
}
.match-strip {
display: inline-block;
min-width: 760px;
padding: 0.75rem;
}
.match-strip .bulk-field {
min-width: 0;
}
.match-strip .strip-value {
background: rgba(255,255,255,0.35);
border-radius: 4px;
color: #111;
font-size: 0.95rem;
font-weight: 700;
min-height: 2rem;
padding: 0.45rem 0.55rem;
text-transform: uppercase;
}
.match-strip .callsign-preview {
align-items: center;
justify-content: center;
}
.match-strip .callsign-preview label {
display: none;
}
.match-strip .callsign-preview .strip-value {
align-items: center;
background: transparent;
display: flex;
font-size: 1.2rem;
justify-content: center;
min-height: 100%;
padding: 0;
text-align: center;
}
.match-strip.strip-kind-local {
padding: 0;
}
.match-strip.strip-kind-local .strip-value {
background: rgba(255,255,255,0.28);
border-radius: 0;
min-height: 0;
padding: 0.2rem;
}
.match-strip.strip-kind-local .strip-registration .strip-value {
background: rgba(255,255,255,0.55);
outline: 2px solid rgba(0,0,0,0.2);
width: 100%;
}
.match-strip.strip-kind-local .strip-registration.callsign-preview .strip-value {
background: transparent;
font-size: 1.35rem;
outline: 0;
}
.movement-table table { .movement-table table {
min-width: 1050px; min-width: 1050px;
} }
@@ -640,6 +719,10 @@
function applySuggestion(suggestion) { function applySuggestion(suggestion) {
if (!suggestion.source) return; if (!suggestion.source) return;
if (document.getElementById('flight-kind').value === 'LOCAL') {
fillIfEmpty('aircraft-type', suggestion.aircraft_type);
return;
}
if (suggestion.movement_id) document.getElementById('matched-movement-id').value = suggestion.movement_id; if (suggestion.movement_id) document.getElementById('matched-movement-id').value = suggestion.movement_id;
if (suggestion.ppr_id) document.getElementById('matched-ppr-id').value = suggestion.ppr_id; if (suggestion.ppr_id) document.getElementById('matched-ppr-id').value = suggestion.ppr_id;
fillSuggestedTime(suggestion.movement_time); fillSuggestedTime(suggestion.movement_time);
@@ -675,30 +758,138 @@
function renderContext(context) { function renderContext(context) {
const panel = document.getElementById('context-panel'); const panel = document.getElementById('context-panel');
const pprs = context.pprs || []; const pprs = context.pprs || [];
const localFlights = context.local_flights || [];
const movements = context.movements || []; const movements = context.movements || [];
if (!pprs.length && !movements.length) { if (!pprs.length && !localFlights.length) {
hideContext(); hideContext();
return; return;
} }
panel.className = `context-panel ${movements.length ? 'warning' : ''}`; panel.className = 'context-panel';
const parts = []; const parts = [];
if (movements.length) {
parts.push(`<strong>Existing movement found.</strong> Saving will update it instead of adding another.`);
parts.push(movements.map(m => `<span class="match-pill">${formatTime(m.timestamp)} ${m.movement_type} #${m.id}</span>`).join(''));
}
if (pprs.length) { if (pprs.length) {
parts.push(`<strong>Matching PPR${pprs.length > 1 ? 's' : ''}:</strong>`); parts.push(`<strong>Matching PPR${pprs.length > 1 ? 's' : ''}:</strong>`);
parts.push(pprs.map(p => { parts.push(renderPPRMatchStrips(pprs));
const encoded = encodeURIComponent(JSON.stringify(p)); }
return `<button type="button" class="match-pill" onclick='selectPPR("${encoded}")'>#${p.id} ${p.aircraft_registration} ${p.from_location || ''}${p.to_location ? ' to ' + p.to_location : ''}</button>`; if (localFlights.length) {
}).join('')); parts.push(`<strong>Existing local flight${localFlights.length > 1 ? 's' : ''}:</strong>`);
parts.push(renderLocalFlightMatchStrips(localFlights));
} }
panel.innerHTML = parts.join('<br>'); panel.innerHTML = parts.join('<br>');
panel.style.display = 'block'; panel.style.display = 'block';
} }
function renderPPRMatchStrips(pprs) {
const kind = document.getElementById('flight-kind').value;
return `<div class="match-strips">${pprs.map(ppr => renderPPRMatchStrip(ppr, kind)).join('')}</div>`;
}
function renderLocalFlightMatchStrips(localFlights) {
return `<div class="match-strips">${localFlights.map(local => renderLocalFlightMatchStrip(local)).join('')}</div>`;
}
function renderPPRMatchStrip(ppr, kind) {
return `
<div class="match-strip-preview" aria-label="Matching PPR ${escapeHtml(ppr.id)} for ${escapeHtml(ppr.aircraft_registration || 'aircraft')}">
<div class="virtual-strip match-strip ${stripClassForKind(kind)} strip-kind-${kind.toLowerCase()}">
${kind === 'LOCAL' ? renderLocalPPRStrip(ppr) : renderStandardPPRStrip(ppr, kind)}
</div>
</div>
`;
}
function renderLocalFlightMatchStrip(local) {
return `
<div class="match-strip-preview" aria-label="Existing local flight ${escapeHtml(local.id)} for ${escapeHtml(local.aircraft_registration || 'aircraft')}">
<div class="virtual-strip match-strip strip-pink strip-kind-local">
${renderExistingLocalStrip(local)}
</div>
</div>
`;
}
function renderStandardPPRStrip(ppr, kind) {
const isArrival = kind === 'ARRIVAL';
const isDeparture = kind === 'DEPARTURE';
const isOverflight = kind === 'OVERFLIGHT';
const pob = kind === 'ARRIVAL' ? ppr.pob_in : (ppr.pob_out || ppr.pob_in);
return `
<div class="bulk-grid">
${stripField('Registration', ppr.aircraft_registration)}
${isArrival ? stripField('LDG', compactIsoTime(ppr.eta)) : ''}
${isDeparture ? stripField('T/O', compactIsoTime(ppr.etd)) : ''}
${isOverflight ? stripField('Contact time', '') : ''}
${isOverflight ? stripField('QSY time', '') : ''}
${stripField('Type', ppr.aircraft_type)}
${stripField('C/SIGN', ppr.callsign || ppr.aircraft_registration, 'callsign-preview')}
${['ARRIVAL', 'OVERFLIGHT'].includes(kind) ? stripField('From', ppr.from_location) : ''}
${['DEPARTURE', 'OVERFLIGHT'].includes(kind) ? stripField('To', ppr.to_location) : ''}
${stripField('POB', pob)}
${['ARRIVAL', 'DEPARTURE'].includes(kind) ? stripField('Runway', '') : ''}
${stripField('Wind', '')}
${stripField('Pressure', '')}
${stripField('Notes', ppr.notes, 'notes-field')}
</div>
`;
}
function renderLocalPPRStrip(ppr) {
return `
<div class="bulk-grid">
<div class="bulk-field strip-field local-slash-cell"></div>
${stripField('LOC / CCTS', 'Local', 'local-nature-cell')}
${stripField('Type', ppr.aircraft_type, 'type-cell')}
${stripField('C/SIGN', ppr.callsign || ppr.aircraft_registration, 'strip-registration callsign-preview')}
${stripField('T/O', compactIsoTime(ppr.etd), 'local-to-cell')}
${stripField('LDG', compactIsoTime(ppr.eta), 'local-ldg-cell')}
${stripField('CCTS', '', 'local-circuit-cell')}
${stripField('POB', ppr.pob_out || ppr.pob_in, 'pob-cell')}
${stripField('Notes', ppr.notes, 'notes-field')}
</div>
`;
}
function renderExistingLocalStrip(local) {
return `
<div class="bulk-grid">
<div class="bulk-field strip-field local-slash-cell"></div>
${stripField('LOC / CCTS', local.flight_type || 'Local', 'local-nature-cell')}
${stripField('Type', local.aircraft_type, 'type-cell')}
${stripField('C/SIGN', local.callsign || local.aircraft_registration, 'strip-registration callsign-preview')}
${stripField('T/O', compactIsoTime(local.takeoff_time || local.departed_time || local.etd), 'local-to-cell')}
${stripField('LDG', compactIsoTime(local.landing_time), 'local-ldg-cell')}
${stripField('CCTS', local.circuits, 'local-circuit-cell')}
${stripField('POB', local.pob, 'pob-cell')}
${stripField('Notes', local.notes, 'notes-field')}
</div>
`;
}
function stripField(label, value, extraClass = '') {
return `
<div class="bulk-field ${extraClass}">
<label>${escapeHtml(label)}</label>
<div class="strip-value">${escapeHtml(value || '')}</div>
</div>
`;
}
function compactIsoTime(value) {
if (!value) return '';
return compactTime(value.slice(11, 16));
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function hideContext() { function hideContext() {
const panel = document.getElementById('context-panel'); const panel = document.getElementById('context-panel');
panel.style.display = 'none'; panel.style.display = 'none';
@@ -717,6 +908,8 @@
document.getElementById('pob').value = kind === 'ARRIVAL' ? (ppr.pob_in || '') : (ppr.pob_out || ppr.pob_in || ''); document.getElementById('pob').value = kind === 'ARRIVAL' ? (ppr.pob_in || '') : (ppr.pob_out || ppr.pob_in || '');
if (kind === 'ARRIVAL' && ppr.eta) document.getElementById('landing-time').value = compactTime(ppr.eta.slice(11, 16)); if (kind === 'ARRIVAL' && ppr.eta) document.getElementById('landing-time').value = compactTime(ppr.eta.slice(11, 16));
if (kind === 'DEPARTURE' && ppr.etd) document.getElementById('takeoff-time').value = compactTime(ppr.etd.slice(11, 16)); if (kind === 'DEPARTURE' && ppr.etd) document.getElementById('takeoff-time').value = compactTime(ppr.etd.slice(11, 16));
if (kind === 'LOCAL' && ppr.etd) document.getElementById('local-takeoff-time').value = compactTime(ppr.etd.slice(11, 16));
if (kind === 'LOCAL' && ppr.eta) document.getElementById('local-landing-time').value = compactTime(ppr.eta.slice(11, 16));
document.getElementById('notes').value = ppr.notes || ''; document.getElementById('notes').value = ppr.notes || '';
} }
@@ -745,7 +938,7 @@
pressure_setting: nullableValue('pressure-setting'), pressure_setting: nullableValue('pressure-setting'),
notes: nullableValue('notes'), notes: nullableValue('notes'),
ppr_id: nullableNumber('matched-ppr-id'), ppr_id: nullableNumber('matched-ppr-id'),
movement_id: nullableNumber('matched-movement-id') movement_id: kind === 'LOCAL' ? null : nullableNumber('matched-movement-id')
}; };
const response = await authFetch(`${API_BASE}/movements/bulk-log`, { const response = await authFetch(`${API_BASE}/movements/bulk-log`, {
+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>