diff --git a/backend/alembic/versions/010_drone_request_agl_altitude.py b/backend/alembic/versions/010_drone_request_agl_altitude.py new file mode 100644 index 0000000..d3bc996 --- /dev/null +++ b/backend/alembic/versions/010_drone_request_agl_altitude.py @@ -0,0 +1,34 @@ +"""Rename drone request altitude to AGL + +Revision ID: 010_drone_request_agl_altitude +Revises: 009_drone_requests +Create Date: 2026-06-29 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '010_drone_request_agl_altitude' +down_revision = '009_drone_requests' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + 'drone_requests', + 'maximum_elevation_ft_amsl', + new_column_name='maximum_elevation_ft_agl', + existing_type=sa.Integer(), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + 'drone_requests', + 'maximum_elevation_ft_agl', + new_column_name='maximum_elevation_ft_amsl', + existing_type=sa.Integer(), + existing_nullable=False, + ) diff --git a/backend/app/api/endpoints/drone_requests.py b/backend/app/api/endpoints/drone_requests.py index 376acc6..7718331 100644 --- a/backend/app/api/endpoints/drone_requests.py +++ b/backend/app/api/endpoints/drone_requests.py @@ -52,7 +52,7 @@ async def _send_drone_email(drone_request, subject: str, message: str): "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, + "maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl, "edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None, }, ) @@ -70,7 +70,7 @@ async def _send_drone_submitted_email(drone_request): "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, + "maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl, "edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None, }, ) @@ -93,7 +93,7 @@ async def _send_drone_tower_notification(drone_request): "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, + "maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl, "inside_frz": "Yes" if drone_request.location_inside_frz else "No", "notes": drone_request.applicant_notes, "requests_url": f"{settings.base_url}/drone-requests", @@ -115,7 +115,7 @@ async def _send_drone_approved_email(drone_request, message: Optional[str] = Non "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, + "maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl, "edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None, }, ) diff --git a/backend/app/models/drone_request.py b/backend/app/models/drone_request.py index fbfbb78..6bb593a 100644 --- a/backend/app/models/drone_request.py +++ b/backend/app/models/drone_request.py @@ -35,7 +35,7 @@ class DroneRequest(Base): 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) + maximum_elevation_ft_agl = Column(Integer, nullable=False) location_description = Column(Text, nullable=True) location_latitude = Column(Float, nullable=False) diff --git a/backend/app/schemas/drone_request.py b/backend/app/schemas/drone_request.py index f96322a..d1e6eb2 100644 --- a/backend/app/schemas/drone_request.py +++ b/backend/app/schemas/drone_request.py @@ -2,7 +2,7 @@ from datetime import date, datetime from enum import Enum from typing import Any, Optional -from pydantic import BaseModel, EmailStr, Field, validator +from pydantic import AliasChoices, BaseModel, EmailStr, Field, validator class DroneRequestStatus(str, Enum): @@ -20,7 +20,11 @@ class DroneRequestBase(BaseModel): 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) + maximum_elevation_ft_agl: int = Field( + ..., + ge=0, + validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"), + ) location_description: Optional[str] = None location_latitude: float = Field(..., ge=-90, le=90) location_longitude: float = Field(..., ge=-180, le=180) @@ -68,7 +72,11 @@ class DroneRequestUpdate(BaseModel): 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) + maximum_elevation_ft_agl: Optional[int] = Field( + None, + ge=0, + validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"), + ) 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) diff --git a/backend/app/templates/drone_request_approved.html b/backend/app/templates/drone_request_approved.html index 989a19d..3fbfbd9 100644 --- a/backend/app/templates/drone_request_approved.html +++ b/backend/app/templates/drone_request_approved.html @@ -53,7 +53,7 @@ Max elevation - {{ maximum_elevation_ft_amsl }} ft AMSL + {{ maximum_elevation_ft_agl }} ft AGL diff --git a/backend/app/templates/drone_request_submitted.html b/backend/app/templates/drone_request_submitted.html index 5860708..f67ad2e 100644 --- a/backend/app/templates/drone_request_submitted.html +++ b/backend/app/templates/drone_request_submitted.html @@ -47,7 +47,7 @@ Max elevation - {{ maximum_elevation_ft_amsl }} ft AMSL + {{ maximum_elevation_ft_agl }} ft AGL diff --git a/backend/app/templates/drone_request_tower_notification.html b/backend/app/templates/drone_request_tower_notification.html index 1b4790f..a9b92fb 100644 --- a/backend/app/templates/drone_request_tower_notification.html +++ b/backend/app/templates/drone_request_tower_notification.html @@ -31,7 +31,7 @@ Completion{{ completion_time }} Location{{ location }} Inside FRZ{{ inside_frz }} - Max elevation{{ maximum_elevation_ft_amsl }} ft AMSL + Max elevation{{ maximum_elevation_ft_agl }} ft AGL Applicant notes{{ notes or '-' }} diff --git a/backend/app/templates/drone_request_update.html b/backend/app/templates/drone_request_update.html index 7243104..3ac882c 100644 --- a/backend/app/templates/drone_request_update.html +++ b/backend/app/templates/drone_request_update.html @@ -47,7 +47,7 @@ Max elevation - {{ maximum_elevation_ft_amsl }} ft AMSL + {{ maximum_elevation_ft_agl }} ft AGL diff --git a/backend/tests/test_drone_requests_api.py b/backend/tests/test_drone_requests_api.py index 37c7a82..ff24e40 100644 --- a/backend/tests/test_drone_requests_api.py +++ b/backend/tests/test_drone_requests_api.py @@ -15,7 +15,7 @@ def drone_payload(**overrides): "estimated_completion_time": "10:30", "estimated_takeoff_at": "2026-06-20T10:00:00", "estimated_completion_at": "2026-06-20T10:30:00", - "maximum_elevation_ft_amsl": 250, + "maximum_elevation_ft_agl": 250, "location_description": "North apron", "location_latitude": 51.623389, "location_longitude": -4.069231, @@ -81,6 +81,23 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404 +def test_drone_request_accepts_legacy_amsl_altitude_key(client, db, monkeypatch): + async def fake_send_email(**kwargs): + return True + + monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email) + + payload = drone_payload() + payload["maximum_elevation_ft_amsl"] = payload.pop("maximum_elevation_ft_agl") + + create_response = client.post("/api/v1/drone-requests/public", json=payload) + + assert create_response.status_code == 200 + assert create_response.json()["maximum_elevation_ft_agl"] == 250 + db_request = db.query(DroneRequest).filter(DroneRequest.id == create_response.json()["id"]).one() + assert db_request.maximum_elevation_ft_agl == 250 + + def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch): sent_emails = [] @@ -100,7 +117,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}") update_response = auth_client.patch( f"/api/v1/drone-requests/{created['id']}", - json={"operator_comments": "Needs tower review", "maximum_elevation_ft_amsl": 200}, + json={"operator_comments": "Needs tower review", "maximum_elevation_ft_agl": 200}, ) status_response = auth_client.patch( f"/api/v1/drone-requests/{created['id']}/status", @@ -116,7 +133,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth assert [request["id"] for request in list_response.json()] == [created["id"]] assert get_response.status_code == 200 assert update_response.status_code == 200 - assert update_response.json()["maximum_elevation_ft_amsl"] == 200 + assert update_response.json()["maximum_elevation_ft_agl"] == 200 assert status_response.status_code == 200 assert status_response.json()["status"] == "APPROVED" assert status_response.json()["operator_comments"] == "Approved below 200ft" @@ -133,7 +150,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth def test_drone_request_not_found_and_validation_paths(auth_client, client): invalid_response = client.post( "/api/v1/drone-requests/public", - json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_amsl=-1), + json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_agl=-1), ) assert invalid_response.status_code == 422 diff --git a/tests/e2e/test_drone_requests.py b/tests/e2e/test_drone_requests.py index 0ebf43d..e8085f4 100644 --- a/tests/e2e/test_drone_requests.py +++ b/tests/e2e/test_drone_requests.py @@ -21,7 +21,7 @@ def drone_payload(operator_name): "estimated_completion_time": "10:30", "estimated_takeoff_at": "2026-06-21T10:00:00", "estimated_completion_at": "2026-06-21T10:30:00", - "maximum_elevation_ft_amsl": 200, + "maximum_elevation_ft_agl": 200, "location_description": "E2E north apron survey", "location_latitude": 51.623389, "location_longitude": -4.069231, diff --git a/web/drone-request.html b/web/drone-request.html index 65d5f2a..e06f158 100644 --- a/web/drone-request.html +++ b/web/drone-request.html @@ -149,8 +149,8 @@
- - + +
@@ -228,7 +228,7 @@ 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('maximum_elevation_ft_agl', request.maximum_elevation_ft_agl); setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No'); setValue('location_latitude', request.location_latitude); setValue('location_longitude', request.location_longitude); @@ -260,7 +260,7 @@ 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')), + maximum_elevation_ft_agl: Number(value('maximum_elevation_ft_agl')), location_latitude: Number(value('location_latitude')), location_longitude: Number(value('location_longitude')), location_description: value('location_description') || null, diff --git a/web/drone-requests.html b/web/drone-requests.html index f69c92f..e6b0c84 100644 --- a/web/drone-requests.html +++ b/web/drone-requests.html @@ -795,7 +795,7 @@ ${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('Max Elevation', `${selectedRequest.maximum_elevation_ft_agl} ft AGL`)} ${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))} ${field('Latitude', selectedRequest.location_latitude)} ${field('Longitude', selectedRequest.location_longitude)} @@ -862,7 +862,7 @@ addLayer(L.marker(point).addTo(map).bindPopup(` ${escapeHtml(selectedRequest.reference_number)}
${escapeHtml(selectedRequest.operator_name)}
- ${selectedRequest.maximum_elevation_ft_amsl} ft AMSL + ${selectedRequest.maximum_elevation_ft_agl} ft AGL `)); }