Drone req handling update

This commit is contained in:
2026-06-22 05:58:26 -04:00
parent 05e7859447
commit f33c12f541
15 changed files with 192 additions and 36 deletions
+1
View File
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
MAIL_PASSWORD=your_mail_password_here
MAIL_FROM=your_mail_from_address_here
MAIL_FROM_NAME=your_mail_from_name_here
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
# Application settings
BASE_URL=your_base_url_here
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
'NEW',
'APPROVED',
'DENIED',
'PENDING',
'CANCELED',
'INFLIGHT',
'COMPLETED',
+39 -4
View File
@@ -17,6 +17,7 @@ from app.schemas.drone_request import (
DroneRequest,
DroneRequestComment,
DroneRequestCreate,
DroneRequestPublicSubmission,
DroneRequestStatus,
DroneRequestStatusUpdate,
DroneRequestUpdate,
@@ -75,6 +76,32 @@ async def _send_drone_submitted_email(drone_request):
)
async def _send_drone_tower_notification(drone_request):
tower_email = settings.drone_request_tower_email or settings.mail_from
await email_service.send_email(
to_email=tower_email,
subject=f"Drone flight request awaiting review {drone_request.reference_number}",
template_name="drone_request_tower_notification.html",
template_vars={
"reference_number": drone_request.reference_number,
"operator_name": drone_request.operator_name,
"operator_id": drone_request.operator_id,
"flyer_name": drone_request.flyer_name,
"flyer_id": drone_request.flyer_id,
"email": drone_request.email,
"phone": drone_request.phone,
"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,
"inside_frz": "Yes" if drone_request.location_inside_frz else "No",
"notes": drone_request.applicant_notes,
"requests_url": f"{settings.base_url}/drone-requests",
},
reply_to=f"{drone_request.operator_name} <{drone_request.email}>",
)
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
await email_service.send_email(
to_email=drone_request.email,
@@ -94,6 +121,13 @@ async def _send_drone_approved_email(drone_request, message: Optional[str] = Non
)
def _public_submission_response(drone_request):
payload = DroneRequest.model_validate(drone_request, from_attributes=True).model_dump(mode="json")
payload["request_id"] = drone_request.reference_number
payload["secure_link"] = f"{settings.base_url}/drone-request.html?token={drone_request.public_token}"
return payload
@router.get("/", response_model=List[DroneRequest])
async def get_drone_requests(
skip: int = 0,
@@ -114,7 +148,7 @@ async def get_drone_requests(
)
@router.post("/public", response_model=DroneRequest)
@router.post("/public", response_model=DroneRequestPublicSubmission)
async def create_public_drone_request(
request: Request,
drone_request_in: DroneRequestCreate,
@@ -131,7 +165,8 @@ async def create_public_drone_request(
await _broadcast(request, "drone_request_created", drone_request)
await _send_drone_submitted_email(drone_request)
return drone_request
await _send_drone_tower_notification(drone_request)
return _public_submission_response(drone_request)
@router.get("/public/edit/{token}", response_model=DroneRequest)
@@ -155,7 +190,7 @@ async def update_drone_request_public(
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]:
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be edited while {drone_request.status.value}",
@@ -182,7 +217,7 @@ async def cancel_drone_request_public(
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]:
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
+1
View File
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
mail_password: str
mail_from: str
mail_from_name: str
drone_request_tower_email: str | None = None
# Application settings
api_v1_str: str = "/api/v1"
-1
View File
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
+5 -1
View File
@@ -9,7 +9,6 @@ class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
@@ -104,3 +103,8 @@ class DroneRequest(DroneRequestBase):
class Config:
from_attributes = True
class DroneRequestPublicSubmission(DroneRequest):
request_id: str
secure_link: str
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Drone Request Awaiting Review</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background: #34495e; color: #ffffff; padding: 24px;">
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
</td>
</tr>
<tr>
<td style="padding: 24px;">
<p style="font-size: 17px; margin: 0 0 18px;">
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
</p>
<p style="margin: 0 0 22px;">
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Completion</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ completion_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Location</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ location }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Inside FRZ</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ inside_frz }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+1
View File
@@ -17,6 +17,7 @@ os.environ.setdefault("MAIL_USERNAME", "test")
os.environ.setdefault("MAIL_PASSWORD", "test")
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
os.environ.setdefault("DRONE_REQUEST_TOWER_EMAIL", "tower@swansea-airport.wales")
os.environ.setdefault("BASE_URL", "http://testserver")
os.environ.setdefault("ENVIRONMENT", "test")
+5 -3
View File
@@ -47,7 +47,9 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
assert created["status"] == "NEW"
assert created["location_inside_frz"] is True
assert created["created_by"] == "public"
assert len(sent_emails) == 1
assert len(sent_emails) == 2
assert sent_emails[1]["to_email"] == "tower@swansea-airport.wales"
assert sent_emails[1]["template_name"] == "drone_request_tower_notification.html"
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
assert db_request.public_token
@@ -64,7 +66,7 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
assert patch_response.json()["operator_name"] == "Updated Rotor Ops"
assert cancel_response.status_code == 200
assert cancel_response.json()["status"] == "CANCELED"
assert len(sent_emails) == 2
assert len(sent_emails) == 3
blocked_patch = client.patch(
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
@@ -125,7 +127,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
assert any("Drone request" in entry and "created" in entry for entry in entries)
assert any("Status changed from NEW to APPROVED" in entry for entry in entries)
assert any("Comment added" in entry for entry in entries)
assert len(sent_emails) == 3
assert len(sent_emails) == 4
def test_drone_request_not_found_and_validation_paths(auth_client, client):
+1
View File
@@ -30,6 +30,7 @@ services:
MAIL_PASSWORD: e2e
MAIL_FROM: e2e@example.com
MAIL_FROM_NAME: PPR E2E
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
web:
container_name: ppr_e2e_web
+1
View File
@@ -24,6 +24,7 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
+1
View File
@@ -36,6 +36,7 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL}
TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
+1 -2
View File
@@ -38,7 +38,6 @@
.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; }
@@ -237,7 +236,7 @@
setValue('notes', request.notes);
setValue('operator_comments', request.operator_comments);
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status);
const locked = !['NEW', 'APPROVED'].includes(request.status);
document.getElementById('locked').style.display = locked ? 'block' : 'none';
document.getElementById('save-btn').disabled = locked;
document.getElementById('cancel-btn').disabled = locked;
+89 -20
View File
@@ -123,6 +123,17 @@
background: #eef6fb;
}
.request-group {
background: #f4f7f9;
border-bottom: 1px solid #dfe6ed;
color: #34495e;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.02em;
padding: 0.55rem 1rem;
text-transform: uppercase;
}
.request-ref {
font-weight: 700;
color: #263645;
@@ -150,7 +161,6 @@
.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; }
@@ -373,15 +383,10 @@
<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 id="request-view-filter" aria-label="Request view">
<option value="active">Active queue</option>
<option value="older">Earlier dates</option>
<option value="closed">Denied / canceled</option>
</select>
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
</div>
@@ -479,6 +484,7 @@
let currentUser = null;
let selectedRequest = null;
let requests = [];
let requestGroups = [];
let map = null;
let mapLayers = [];
let frzGeometry = null;
@@ -486,7 +492,10 @@
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('message-form').addEventListener('submit', sendMessageFromModal);
document.getElementById('status-filter').addEventListener('change', loadRequests);
document.getElementById('request-view-filter').addEventListener('change', () => {
clearSelectedRequest();
loadRequests();
});
initializeAuth();
initializeMap();
connectWebSocket();
@@ -607,15 +616,13 @@
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 view = document.getElementById('request-view-filter').value;
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();
requestGroups = await loadRequestGroups(view);
requests = requestGroups.flatMap(group => group.items);
renderRequestList();
if (selectedRequest) {
const fresh = requests.find(r => r.id === selectedRequest.id);
@@ -634,16 +641,79 @@
}
}
async function loadRequestGroups(view) {
const today = getLocalDateString(new Date());
const yesterday = getLocalDateString(addDays(new Date(), -1));
if (view === 'older') {
const older = await fetchDroneRequests({ date_to: yesterday });
return [{ label: 'Earlier dated requests', items: older }];
}
if (view === 'closed') {
const [denied, canceled] = await Promise.all([
fetchDroneRequests({ status: 'DENIED' }),
fetchDroneRequests({ status: 'CANCELED' }),
]);
return [
{ label: 'Denied requests', items: denied },
{ label: 'Canceled requests', items: canceled },
];
}
const [newRequests, approvedToday, approvedUpcoming] = await Promise.all([
fetchDroneRequests({ status: 'NEW' }),
fetchDroneRequests({ status: 'APPROVED', date_from: today, date_to: today }),
fetchDroneRequests({ status: 'APPROVED', date_from: getLocalDateString(addDays(new Date(), 1)) }),
]);
return [
{ label: 'New requests', items: newRequests },
{ label: "Today's approved flights", items: approvedToday },
{ label: 'Upcoming approved flights', items: approvedUpcoming },
];
}
async function fetchDroneRequests(params) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
});
const url = `/api/v1/drone-requests/${search.toString() ? `?${search.toString()}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error('Failed to load drone requests');
return response.json();
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getLocalDateString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function renderRequestList() {
document.getElementById('request-count').textContent = requests.length;
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
const body = document.getElementById('request-list-body');
if (!requests.length) {
if (!requestGroups.length) {
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
return;
}
body.innerHTML = requests.map(req => `
body.innerHTML = requestGroups.map(group => `
<div class="request-group">${escapeHtml(group.label)} - ${group.items.length}</div>
${group.items.length ? group.items.map(renderRequestRow).join('') : '<div class="empty-state">None</div>'}
`).join('');
}
function renderRequestRow(req) {
return `
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
<div>
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
@@ -656,7 +726,7 @@
</div>
<span class="status-pill status-${req.status}">${req.status}</span>
</button>
`).join('');
`;
}
async function selectRequest(id, collapseQueue = true) {
@@ -753,7 +823,6 @@
actions.innerHTML = `
<select id="status-select" class="lifecycle-control" 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>
+2 -3
View File
@@ -386,10 +386,12 @@
<body>
<header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<!-- Booking QR hidden until the Book Out flow is ready.
<div class="qr-code-container">
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
<div class="qr-label">Book Out</div>
</div>
-->
<h1>Flight Information</h1>
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header>
@@ -893,9 +895,6 @@
// Initialize Christmas mode
initChristmasMode();
// Load booking QR code
generateBookingQR();
loadArrivals();
loadDepartures();