Drone req handling update

This commit is contained in:
2026-06-22 05:58:26 -04:00
parent 05e7859447
commit f33c12f541
15 changed files with 192 additions and 36 deletions
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
'NEW',
'APPROVED',
'DENIED',
'PENDING',
'CANCELED',
'INFLIGHT',
'COMPLETED',
+39 -4
View File
@@ -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}",
+1
View File
@@ -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"
-1
View File
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
+5 -1
View File
@@ -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
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Drone Request Awaiting Review</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background: #34495e; color: #ffffff; padding: 24px;">
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
</td>
</tr>
<tr>
<td style="padding: 24px;">
<p style="font-size: 17px; margin: 0 0 18px;">
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
</p>
<p style="margin: 0 0 22px;">
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td></tr>
<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;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+1
View File
@@ -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")
+5 -3
View File
@@ -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):