Drones to use AGL
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user