diff --git a/backend/app/api/endpoints/drone_requests.py b/backend/app/api/endpoints/drone_requests.py index 61331e3..3de014f 100644 --- a/backend/app/api/endpoints/drone_requests.py +++ b/backend/app/api/endpoints/drone_requests.py @@ -7,6 +7,7 @@ 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.frz import swansea_frz_geojson 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 @@ -56,6 +57,43 @@ async def _send_drone_email(drone_request, subject: str, message: str): ) +async def _send_drone_submitted_email(drone_request): + await email_service.send_email( + to_email=drone_request.email, + subject=f"Drone flight request received {drone_request.reference_number}", + template_name="drone_request_submitted.html", + template_vars={ + "name": drone_request.operator_name, + "reference_number": drone_request.reference_number, + "status": drone_request.status.value, + "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, + }, + ) + + +async def _send_drone_approved_email(drone_request, message: Optional[str] = None): + await email_service.send_email( + to_email=drone_request.email, + subject=f"Drone request {drone_request.reference_number} APPROVED", + template_name="drone_request_approved.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, @@ -92,11 +130,7 @@ async def create_public_drone_request( ) 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.", - ) + await _send_drone_submitted_email(drone_request) return drone_request @@ -172,6 +206,11 @@ async def cancel_drone_request_public( return cancelled_request +@router.get("/frz") +async def get_swansea_drone_frz(): + return swansea_frz_geojson() + + @router.get("/{request_id}", response_model=DroneRequest) async def get_drone_request( request_id: int, @@ -230,11 +269,14 @@ async def update_drone_request_status( 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, - ) + if drone_request.status == DroneRequestStatus.APPROVED: + await _send_drone_approved_email(drone_request, status_update.comment) + else: + await _send_drone_email( + drone_request, + f"Drone request {drone_request.reference_number} {drone_request.status.value}", + message, + ) return drone_request diff --git a/backend/app/core/frz.py b/backend/app/core/frz.py new file mode 100644 index 0000000..2d144fd --- /dev/null +++ b/backend/app/core/frz.py @@ -0,0 +1,156 @@ +SWANSEA_FRZ_POLYGONS = [ + { + "name": "EGR1U003A SWANSEA", + "coordinates": [ + [51.6385691199, -4.0677777778], + [51.6383925433, -4.0732789905], + [51.6378646894, -4.0787217619], + [51.6369911656, -4.0840482758], + [51.6357812508, -4.0892019586], + [51.6342477966, -4.0941280843], + [51.6324070894, -4.0987743581], + [51.6302786768, -4.1030914745], + [51.6278851591, -4.1070336417], + [51.6252519474, -4.1105590688], + [51.6224069933, -4.1136304084], + [51.6193804903, -4.1162151518], + [51.6162045529, -4.1182859716], + [51.6129128741, -4.1198210079], + [51.6095403677, -4.1208040969], + [51.606122797, -4.1212249367], + [51.6026963949, -4.1210791924], + [51.5992974802, -4.120368536], + [51.595962072, -4.1191006239], + [51.592725509, -4.1172890099], + [51.5896220749, -4.1149529973], + [51.5866846367, -4.1121174298], + [51.5839442973, -4.1088124245], + [51.5814300673, -4.1050730509], + [51.5791685587, -4.1009389566], + [51.5771837053, -4.0964539477], + [51.5754965099, -4.0916655245], + [51.5741248235, -4.0866243798], + [51.5730831575, -4.0813838645], + [51.572382531, -4.0759994259], + [51.5720303553, -4.0705280237], + [51.5720303553, -4.0650275318], + [51.572382531, -4.0595561297], + [51.5730831575, -4.054171691], + [51.5741248235, -4.0489311758], + [51.5754965099, -4.0438900311], + [51.5771837053, -4.0391016078], + [51.5791685587, -4.0346165989], + [51.5814300673, -4.0304825047], + [51.5839442973, -4.026743131], + [51.5866846367, -4.0234381258], + [51.5896220749, -4.0206025582], + [51.592725509, -4.0182665456], + [51.595962072, -4.0164549317], + [51.5992974802, -4.0151870195], + [51.6026963949, -4.0144763632], + [51.606122797, -4.0143306189], + [51.6095403677, -4.0147514587], + [51.6129128741, -4.0157345476], + [51.6162045529, -4.017269584], + [51.6193804903, -4.0193404037], + [51.6224069933, -4.0219251472], + [51.6252519474, -4.0249964868], + [51.6278851591, -4.0285219138], + [51.6302786768, -4.0324640811], + [51.6324070894, -4.0367811974], + [51.6342477966, -4.0414274713], + [51.6357812508, -4.0463535969], + [51.6369911656, -4.0515072798], + [51.6378646894, -4.0568337937], + [51.6383925433, -4.0622765651], + [51.6385691199, -4.0677777778], + ], + }, + { + "name": "EGR1U003B SWANSEA RWY 04", + "coordinates": [ + [51.5614305556, -4.1105694444], + [51.5760447778, -4.0933516667], + [51.5775992074, -4.0974772875], + [51.5793789018, -4.1013615228], + [51.5813693889, -4.1049727778], + [51.5667527778, -4.1221888889], + [51.5614305556, -4.1105694444], + ], + }, + { + "name": "EGR1U003C SWANSEA RWY 22", + "coordinates": [ + [51.6483027778, -4.0259555556], + [51.6345286389, -4.0422406389], + [51.632975828, -4.0381074205], + [51.631197314, -4.0342163026], + [51.6292076111, -4.030599], + [51.6429805556, -4.0143111111], + [51.6483027778, -4.0259555556], + ], + }, + { + "name": "EGR1U003D SWANSEA RWY 10", + "coordinates": [ + [51.6016305556, -4.1483194444], + [51.5997253611, -4.1204896111], + [51.602737017, -4.1210842429], + [51.605769878, -4.1212361072], + [51.60879875, -4.1209438611], + [51.6105638889, -4.1467305556], + [51.6016305556, -4.1483194444], + ], + }, + { + "name": "EGR1U003E SWANSEA RWY 28", + "coordinates": [ + [51.5998777778, -3.9918916667], + [51.6014628333, -4.0146683056], + [51.5984676719, -4.015448363], + [51.5955291688, -4.0166629251], + [51.5926717222, -4.0183018333], + [51.5909444444, -3.9934777778], + [51.5998777778, -3.9918916667], + ], + }, +] + + +def point_inside_swansea_frz(lat: float, lng: float) -> bool: + """Return whether a point is inside the Swansea UAS FRZ polygons from the KML source.""" + return any(_point_inside_polygon(lat, lng, polygon["coordinates"]) for polygon in SWANSEA_FRZ_POLYGONS) + + +def swansea_frz_geojson() -> dict: + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": polygon["name"]}, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [lng, lat] for lat, lng in polygon["coordinates"] + ]], + }, + } + for polygon in SWANSEA_FRZ_POLYGONS + ], + } + + +def _point_inside_polygon(lat: float, lng: float, coordinates: list[list[float]]) -> bool: + inside = False + j = len(coordinates) - 1 + for i, (point_lat, point_lng) in enumerate(coordinates): + previous_lat, previous_lng = coordinates[j] + intersects = ( + (point_lat > lat) != (previous_lat > lat) + and lng < (previous_lng - point_lng) * (lat - point_lat) / (previous_lat - point_lat) + point_lng + ) + if intersects: + inside = not inside + j = i + return inside diff --git a/backend/app/crud/crud_drone_request.py b/backend/app/crud/crud_drone_request.py index dedaffc..45102c8 100644 --- a/backend/app/crud/crud_drone_request.py +++ b/backend/app/crud/crud_drone_request.py @@ -5,6 +5,7 @@ from typing import List, Optional from sqlalchemy import desc, func from sqlalchemy.orm import Session +from app.core.frz import point_inside_swansea_frz from app.crud.crud_journal import journal as crud_journal from app.models.drone_request import DroneRequest, DroneRequestStatus from app.models.journal import EntityType @@ -52,10 +53,13 @@ class CRUDDroneRequest: reference_number = self._generate_reference(db) payload = obj_in.dict() notes = payload.pop("notes", None) + payload.pop("prototype_overlay", None) + payload.pop("location_inside_frz", None) db_obj = DroneRequest( **payload, applicant_notes=notes, + location_inside_frz=point_inside_swansea_frz(payload["location_latitude"], payload["location_longitude"]), reference_number=reference_number, public_token=secrets.token_urlsafe(64), status=DroneRequestStatus.NEW, @@ -88,6 +92,13 @@ class CRUDDroneRequest: update_data = obj_in.dict(exclude_unset=True) if "notes" in update_data: update_data["applicant_notes"] = update_data.pop("notes") + update_data.pop("prototype_overlay", None) + update_data.pop("location_inside_frz", None) + + if "location_latitude" in update_data or "location_longitude" in update_data: + lat = update_data.get("location_latitude", db_obj.location_latitude) + lng = update_data.get("location_longitude", db_obj.location_longitude) + update_data["location_inside_frz"] = point_inside_swansea_frz(lat, lng) changes = [] for field, value in update_data.items(): diff --git a/backend/app/templates/drone_request_approved.html b/backend/app/templates/drone_request_approved.html new file mode 100644 index 0000000..989a19d --- /dev/null +++ b/backend/app/templates/drone_request_approved.html @@ -0,0 +1,74 @@ + + +
+ +| + + | +
| + + | +
Hello {{ name }},
-{{ message }}
+ +|
+
-
- {% if edit_url %}
- You can view, update, or cancel your drone request using this secure link. - {% endif %} - -Please quote your reference number in any replies. diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index f37dd2c..4731e4f 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -188,8 +188,8 @@ echo "" # Start the application with appropriate settings if [ "${ENVIRONMENT}" = "production" ]; then - echo "Starting in PRODUCTION mode with multiple workers..." - exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${WORKERS:-4} + echo "Starting in PRODUCTION mode with a single worker for in-process WebSocket broadcasts..." + exec uvicorn app.main:app --host 0.0.0.0 --port 8000 else echo "Starting in DEVELOPMENT mode with auto-reload..." exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/backend/tests/test_drone_requests_api.py b/backend/tests/test_drone_requests_api.py index 2800956..5fd05db 100644 --- a/backend/tests/test_drone_requests_api.py +++ b/backend/tests/test_drone_requests_api.py @@ -1,3 +1,4 @@ +from app.core.frz import point_inside_swansea_frz from app.models.drone_request import DroneRequest @@ -16,11 +17,14 @@ def drone_payload(**overrides): "estimated_completion_at": "2026-06-20T10:30:00", "maximum_elevation_ft_amsl": 250, "location_description": "North apron", - "location_latitude": 51.47, - "location_longitude": -0.45, - "location_inside_frz": "yes", + "location_latitude": 51.623389, + "location_longitude": -4.069231, + "location_inside_frz": "no", "notes": "Survey flight", - "prototype_overlay": {"radius_nm": 1}, + "prototype_overlay": { + "airport_reference_point": {"lat": 0, "lng": 0}, + "frz_radius_metres": 1, + }, } payload.update(overrides) return payload @@ -142,3 +146,11 @@ def test_drone_request_not_found_and_validation_paths(auth_client, client): == 404 ) assert auth_client.get("/api/v1/drone-requests/404/journal").status_code == 404 + + +def test_swansea_frz_runway_extensions_start_at_thresholds(): + assert point_inside_swansea_frz(51.626825, -4.037672) is True + assert point_inside_swansea_frz(51.583775, -4.097928) is True + assert point_inside_swansea_frz(51.603007, -4.025604) is True + assert point_inside_swansea_frz(51.607593, -4.109996) is True + assert point_inside_swansea_frz(51.68000, -4.06780) is False diff --git a/web/drone-requests.html b/web/drone-requests.html index 1380982..e853fd3 100644 --- a/web/drone-requests.html +++ b/web/drone-requests.html @@ -324,6 +324,7 @@ let requests = []; let map = null; let mapLayers = []; + let frzGeometry = null; document.addEventListener('DOMContentLoaded', () => { document.getElementById('login-form').addEventListener('submit', handleLogin); @@ -339,6 +340,18 @@ maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); + loadFrzGeometry(); + } + + async function loadFrzGeometry() { + try { + const response = await fetch('/api/v1/drone-requests/frz'); + if (!response.ok) throw new Error('Failed to load FRZ geometry'); + frzGeometry = await response.json(); + renderMap(); + } catch (err) { + console.warn('Unable to load FRZ geometry', err); + } } function initializeAuth() { @@ -514,67 +527,35 @@ function renderMap() { clearMapLayers(); - if (!selectedRequest || !map) return; + if (!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; + if (frzGeometry) { + const frzLayer = L.geoJSON(frzGeometry, { + style: { + color: '#e74c3c', + weight: 2, + fillColor: '#e74c3c', + fillOpacity: 0.10 + } + }).addTo(map); + addLayer(frzLayer); + } - 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(` - ${escapeHtml(selectedRequest.reference_number)}- ${escapeHtml(selectedRequest.operator_name)} - ${selectedRequest.maximum_elevation_ft_amsl} ft AMSL - `)); + if (selectedRequest) { + const point = [selectedRequest.location_latitude, selectedRequest.location_longitude]; + addLayer(L.marker(point).addTo(map).bindPopup(` + ${escapeHtml(selectedRequest.reference_number)} + ${escapeHtml(selectedRequest.operator_name)} + ${selectedRequest.maximum_elevation_ft_amsl} ft AMSL + `)); + } + if (!mapLayers.length) return; 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); } |