From f33c12f541d9b38dad3e8f2afa1754ef087bd4ae Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 22 Jun 2026 05:58:26 -0400 Subject: [PATCH] Drone req handling update --- .env.example | 3 +- .../alembic/versions/009_drone_requests.py | 1 - backend/app/api/endpoints/drone_requests.py | 43 ++++++- backend/app/core/config.py | 1 + backend/app/models/drone_request.py | 1 - backend/app/schemas/drone_request.py | 6 +- .../drone_request_tower_notification.html | 44 +++++++ backend/tests/conftest.py | 1 + backend/tests/test_drone_requests_api.py | 8 +- docker-compose.e2e.yml | 1 + docker-compose.prod.yml | 1 + docker-compose.yml | 1 + web/drone-request.html | 3 +- web/drone-requests.html | 109 ++++++++++++++---- web/index.html | 5 +- 15 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 backend/app/templates/drone_request_tower_notification.html diff --git a/.env.example b/.env.example index f08d18d..2482aaf 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082 # phpMyAdmin Configuration PMA_HOST=db UPLOAD_LIMIT=50M -PMA_PORT_EXTERNAL=8083 \ No newline at end of file +PMA_PORT_EXTERNAL=8083 diff --git a/backend/alembic/versions/009_drone_requests.py b/backend/alembic/versions/009_drone_requests.py index ac9fa21..adc828e 100644 --- a/backend/alembic/versions/009_drone_requests.py +++ b/backend/alembic/versions/009_drone_requests.py @@ -18,7 +18,6 @@ drone_status = sa.Enum( 'NEW', 'APPROVED', 'DENIED', - 'PENDING', 'CANCELED', 'INFLIGHT', 'COMPLETED', diff --git a/backend/app/api/endpoints/drone_requests.py b/backend/app/api/endpoints/drone_requests.py index 3de014f..376acc6 100644 --- a/backend/app/api/endpoints/drone_requests.py +++ b/backend/app/api/endpoints/drone_requests.py @@ -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}", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 72bea08..dbf6593 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/models/drone_request.py b/backend/app/models/drone_request.py index 72f7d1d..fbfbb78 100644 --- a/backend/app/models/drone_request.py +++ b/backend/app/models/drone_request.py @@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum): NEW = "NEW" APPROVED = "APPROVED" DENIED = "DENIED" - PENDING = "PENDING" CANCELED = "CANCELED" INFLIGHT = "INFLIGHT" COMPLETED = "COMPLETED" diff --git a/backend/app/schemas/drone_request.py b/backend/app/schemas/drone_request.py index eb11cd1..f96322a 100644 --- a/backend/app/schemas/drone_request.py +++ b/backend/app/schemas/drone_request.py @@ -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 diff --git a/backend/app/templates/drone_request_tower_notification.html b/backend/app/templates/drone_request_tower_notification.html new file mode 100644 index 0000000..1b4790f --- /dev/null +++ b/backend/app/templates/drone_request_tower_notification.html @@ -0,0 +1,44 @@ + + + + + Drone Request Awaiting Review + + + + + + +
+ + + + + + + +
+

Drone request awaiting review

+

{{ reference_number }}

+
+

+ A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical. +

+

+ Open drone requests +

+ + + + + + + + + + +
Operator{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}
Flyer{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}
Contact{{ email }}{% if phone %} / {{ phone }}{% endif %}
Takeoff{{ takeoff_time }}
Completion{{ completion_time }}
Location{{ location }}
Inside FRZ{{ inside_frz }}
Max elevation{{ maximum_elevation_ft_amsl }} ft AMSL
Applicant notes{{ notes or '-' }}
+
+
+ + diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fc9d919..bfffd54 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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") diff --git a/backend/tests/test_drone_requests_api.py b/backend/tests/test_drone_requests_api.py index 5fd05db..37c7a82 100644 --- a/backend/tests/test_drone_requests_api.py +++ b/backend/tests/test_drone_requests_api.py @@ -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): diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 8c0c427..aadc5c5 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index dc5e6a0..9a21789 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index 423b7c8..34fd4d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/web/drone-request.html b/web/drone-request.html index 5189968..e71d0cf 100644 --- a/web/drone-request.html +++ b/web/drone-request.html @@ -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; diff --git a/web/drone-requests.html b/web/drone-requests.html index f9f8a9a..dc2bbd0 100644 --- a/web/drone-requests.html +++ b/web/drone-requests.html @@ -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 @@
Requests from the public drone flight form
- + + +
@@ -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 = '
Loading requests...
'; 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 = '
No requests match the current filter.
'; return; } - body.innerHTML = requests.map(req => ` + body.innerHTML = requestGroups.map(group => ` +
${escapeHtml(group.label)} - ${group.items.length}
+ ${group.items.length ? group.items.map(renderRequestRow).join('') : '
None
'} + `).join(''); + } + + function renderRequestRow(req) { + return ` - `).join(''); + `; } async function selectRequest(id, collapseQueue = true) { @@ -753,7 +823,6 @@ actions.innerHTML = `