Drones to use AGL

This commit is contained in:
2026-06-29 06:26:37 -04:00
parent 4b6dd9c93c
commit 8d8cb9ccad
12 changed files with 82 additions and 23 deletions
@@ -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,
)
+4 -4
View File
@@ -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,
},
)
+1 -1
View File
@@ -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)
+11 -3
View File
@@ -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)
@@ -53,7 +53,7 @@
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
</tr>
</table>
@@ -47,7 +47,7 @@
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
</tr>
</table>
@@ -31,7 +31,7 @@
<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;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</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>
@@ -47,7 +47,7 @@
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
</tr>
</table>
+21 -4
View File
@@ -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
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -149,8 +149,8 @@
<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>
<label for="maximum_elevation_ft_agl">Maximum elevation ft AGL</label>
<input id="maximum_elevation_ft_agl" type="number" min="0" required>
</div>
<div>
<label for="location_inside_frz">Inside FRZ</label>
@@ -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,
+2 -2
View File
@@ -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(`
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
${escapeHtml(selectedRequest.operator_name)}<br>
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL
${selectedRequest.maximum_elevation_ft_agl} ft AGL
`));
}