Drone req handling update
This commit is contained in:
+2
-1
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
|
|||||||
MAIL_PASSWORD=your_mail_password_here
|
MAIL_PASSWORD=your_mail_password_here
|
||||||
MAIL_FROM=your_mail_from_address_here
|
MAIL_FROM=your_mail_from_address_here
|
||||||
MAIL_FROM_NAME=your_mail_from_name_here
|
MAIL_FROM_NAME=your_mail_from_name_here
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
BASE_URL=your_base_url_here
|
BASE_URL=your_base_url_here
|
||||||
@@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082
|
|||||||
# phpMyAdmin Configuration
|
# phpMyAdmin Configuration
|
||||||
PMA_HOST=db
|
PMA_HOST=db
|
||||||
UPLOAD_LIMIT=50M
|
UPLOAD_LIMIT=50M
|
||||||
PMA_PORT_EXTERNAL=8083
|
PMA_PORT_EXTERNAL=8083
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
|
|||||||
'NEW',
|
'NEW',
|
||||||
'APPROVED',
|
'APPROVED',
|
||||||
'DENIED',
|
'DENIED',
|
||||||
'PENDING',
|
|
||||||
'CANCELED',
|
'CANCELED',
|
||||||
'INFLIGHT',
|
'INFLIGHT',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.schemas.drone_request import (
|
|||||||
DroneRequest,
|
DroneRequest,
|
||||||
DroneRequestComment,
|
DroneRequestComment,
|
||||||
DroneRequestCreate,
|
DroneRequestCreate,
|
||||||
|
DroneRequestPublicSubmission,
|
||||||
DroneRequestStatus,
|
DroneRequestStatus,
|
||||||
DroneRequestStatusUpdate,
|
DroneRequestStatusUpdate,
|
||||||
DroneRequestUpdate,
|
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):
|
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
|
||||||
await email_service.send_email(
|
await email_service.send_email(
|
||||||
to_email=drone_request.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])
|
@router.get("/", response_model=List[DroneRequest])
|
||||||
async def get_drone_requests(
|
async def get_drone_requests(
|
||||||
skip: int = 0,
|
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(
|
async def create_public_drone_request(
|
||||||
request: Request,
|
request: Request,
|
||||||
drone_request_in: DroneRequestCreate,
|
drone_request_in: DroneRequestCreate,
|
||||||
@@ -131,7 +165,8 @@ async def create_public_drone_request(
|
|||||||
|
|
||||||
await _broadcast(request, "drone_request_created", drone_request)
|
await _broadcast(request, "drone_request_created", drone_request)
|
||||||
await _send_drone_submitted_email(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)
|
@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)
|
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||||
if not drone_request:
|
if not drone_request:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
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)
|
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||||
if not drone_request:
|
if not drone_request:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
|||||||
mail_password: str
|
mail_password: str
|
||||||
mail_from: str
|
mail_from: str
|
||||||
mail_from_name: str
|
mail_from_name: str
|
||||||
|
drone_request_tower_email: str | None = None
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
api_v1_str: str = "/api/v1"
|
api_v1_str: str = "/api/v1"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
|
|||||||
NEW = "NEW"
|
NEW = "NEW"
|
||||||
APPROVED = "APPROVED"
|
APPROVED = "APPROVED"
|
||||||
DENIED = "DENIED"
|
DENIED = "DENIED"
|
||||||
PENDING = "PENDING"
|
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
INFLIGHT = "INFLIGHT"
|
INFLIGHT = "INFLIGHT"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class DroneRequestStatus(str, Enum):
|
|||||||
NEW = "NEW"
|
NEW = "NEW"
|
||||||
APPROVED = "APPROVED"
|
APPROVED = "APPROVED"
|
||||||
DENIED = "DENIED"
|
DENIED = "DENIED"
|
||||||
PENDING = "PENDING"
|
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
INFLIGHT = "INFLIGHT"
|
INFLIGHT = "INFLIGHT"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
@@ -104,3 +103,8 @@ class DroneRequest(DroneRequestBase):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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>
|
||||||
@@ -17,6 +17,7 @@ os.environ.setdefault("MAIL_USERNAME", "test")
|
|||||||
os.environ.setdefault("MAIL_PASSWORD", "test")
|
os.environ.setdefault("MAIL_PASSWORD", "test")
|
||||||
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
||||||
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
|
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("BASE_URL", "http://testserver")
|
||||||
os.environ.setdefault("ENVIRONMENT", "test")
|
os.environ.setdefault("ENVIRONMENT", "test")
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
|
|||||||
assert created["status"] == "NEW"
|
assert created["status"] == "NEW"
|
||||||
assert created["location_inside_frz"] is True
|
assert created["location_inside_frz"] is True
|
||||||
assert created["created_by"] == "public"
|
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()
|
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
|
||||||
assert db_request.public_token
|
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 patch_response.json()["operator_name"] == "Updated Rotor Ops"
|
||||||
assert cancel_response.status_code == 200
|
assert cancel_response.status_code == 200
|
||||||
assert cancel_response.json()["status"] == "CANCELED"
|
assert cancel_response.json()["status"] == "CANCELED"
|
||||||
assert len(sent_emails) == 2
|
assert len(sent_emails) == 3
|
||||||
|
|
||||||
blocked_patch = client.patch(
|
blocked_patch = client.patch(
|
||||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
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("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("Status changed from NEW to APPROVED" in entry for entry in entries)
|
||||||
assert any("Comment added" 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):
|
def test_drone_request_not_found_and_validation_paths(auth_client, client):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
MAIL_PASSWORD: e2e
|
MAIL_PASSWORD: e2e
|
||||||
MAIL_FROM: e2e@example.com
|
MAIL_FROM: e2e@example.com
|
||||||
MAIL_FROM_NAME: PPR E2E
|
MAIL_FROM_NAME: PPR E2E
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
|
||||||
|
|
||||||
web:
|
web:
|
||||||
container_name: ppr_e2e_web
|
container_name: ppr_e2e_web
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
MAIL_FROM: ${MAIL_FROM}
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
TAG: ${TAG}
|
TAG: ${TAG}
|
||||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
MAIL_FROM: ${MAIL_FROM}
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
TOWER_NAME: ${TOWER_NAME}
|
TOWER_NAME: ${TOWER_NAME}
|
||||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
.status-NEW { background: #3498db; }
|
.status-NEW { background: #3498db; }
|
||||||
.status-APPROVED { background: #27ae60; }
|
.status-APPROVED { background: #27ae60; }
|
||||||
.status-DENIED { background: #c0392b; }
|
.status-DENIED { background: #c0392b; }
|
||||||
.status-PENDING { background: #f39c12; }
|
|
||||||
.status-CANCELED { background: #7f8c8d; }
|
.status-CANCELED { background: #7f8c8d; }
|
||||||
.status-INFLIGHT { background: #8e44ad; }
|
.status-INFLIGHT { background: #8e44ad; }
|
||||||
.status-COMPLETED { background: #2c3e50; }
|
.status-COMPLETED { background: #2c3e50; }
|
||||||
@@ -237,7 +236,7 @@
|
|||||||
setValue('notes', request.notes);
|
setValue('notes', request.notes);
|
||||||
setValue('operator_comments', request.operator_comments);
|
setValue('operator_comments', request.operator_comments);
|
||||||
|
|
||||||
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status);
|
const locked = !['NEW', 'APPROVED'].includes(request.status);
|
||||||
document.getElementById('locked').style.display = locked ? 'block' : 'none';
|
document.getElementById('locked').style.display = locked ? 'block' : 'none';
|
||||||
document.getElementById('save-btn').disabled = locked;
|
document.getElementById('save-btn').disabled = locked;
|
||||||
document.getElementById('cancel-btn').disabled = locked;
|
document.getElementById('cancel-btn').disabled = locked;
|
||||||
|
|||||||
+89
-20
@@ -123,6 +123,17 @@
|
|||||||
background: #eef6fb;
|
background: #eef6fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-group {
|
||||||
|
background: #f4f7f9;
|
||||||
|
border-bottom: 1px solid #dfe6ed;
|
||||||
|
color: #34495e;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.request-ref {
|
.request-ref {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #263645;
|
color: #263645;
|
||||||
@@ -150,7 +161,6 @@
|
|||||||
.status-NEW { background: #3498db; }
|
.status-NEW { background: #3498db; }
|
||||||
.status-APPROVED { background: #27ae60; }
|
.status-APPROVED { background: #27ae60; }
|
||||||
.status-DENIED { background: #c0392b; }
|
.status-DENIED { background: #c0392b; }
|
||||||
.status-PENDING { background: #f39c12; }
|
|
||||||
.status-CANCELED { background: #7f8c8d; }
|
.status-CANCELED { background: #7f8c8d; }
|
||||||
.status-INFLIGHT { background: #8e44ad; }
|
.status-INFLIGHT { background: #8e44ad; }
|
||||||
.status-COMPLETED { background: #2c3e50; }
|
.status-COMPLETED { background: #2c3e50; }
|
||||||
@@ -373,15 +383,10 @@
|
|||||||
<div class="detail-meta">Requests from the public drone flight form</div>
|
<div class="detail-meta">Requests from the public drone flight form</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<select id="status-filter" aria-label="Status filter">
|
<select id="request-view-filter" aria-label="Request view">
|
||||||
<option value="">All statuses</option>
|
<option value="active">Active queue</option>
|
||||||
<option value="NEW">New</option>
|
<option value="older">Earlier dates</option>
|
||||||
<option value="PENDING">Pending</option>
|
<option value="closed">Denied / canceled</option>
|
||||||
<option value="APPROVED">Approved</option>
|
|
||||||
<option value="DENIED">Denied</option>
|
|
||||||
<option value="CANCELED">Canceled</option>
|
|
||||||
<option value="INFLIGHT">Inflight</option>
|
|
||||||
<option value="COMPLETED">Completed</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,6 +484,7 @@
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let selectedRequest = null;
|
let selectedRequest = null;
|
||||||
let requests = [];
|
let requests = [];
|
||||||
|
let requestGroups = [];
|
||||||
let map = null;
|
let map = null;
|
||||||
let mapLayers = [];
|
let mapLayers = [];
|
||||||
let frzGeometry = null;
|
let frzGeometry = null;
|
||||||
@@ -486,7 +492,10 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
document.getElementById('message-form').addEventListener('submit', sendMessageFromModal);
|
document.getElementById('message-form').addEventListener('submit', sendMessageFromModal);
|
||||||
document.getElementById('status-filter').addEventListener('change', loadRequests);
|
document.getElementById('request-view-filter').addEventListener('change', () => {
|
||||||
|
clearSelectedRequest();
|
||||||
|
loadRequests();
|
||||||
|
});
|
||||||
initializeAuth();
|
initializeAuth();
|
||||||
initializeMap();
|
initializeMap();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
@@ -607,15 +616,13 @@
|
|||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
const status = document.getElementById('status-filter').value;
|
const view = document.getElementById('request-view-filter').value;
|
||||||
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
|
|
||||||
const body = document.getElementById('request-list-body');
|
const body = document.getElementById('request-list-body');
|
||||||
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(url);
|
requestGroups = await loadRequestGroups(view);
|
||||||
if (!response.ok) throw new Error('Failed to load drone requests');
|
requests = requestGroups.flatMap(group => group.items);
|
||||||
requests = await response.json();
|
|
||||||
renderRequestList();
|
renderRequestList();
|
||||||
if (selectedRequest) {
|
if (selectedRequest) {
|
||||||
const fresh = requests.find(r => r.id === selectedRequest.id);
|
const fresh = requests.find(r => r.id === selectedRequest.id);
|
||||||
@@ -634,16 +641,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRequestGroups(view) {
|
||||||
|
const today = getLocalDateString(new Date());
|
||||||
|
const yesterday = getLocalDateString(addDays(new Date(), -1));
|
||||||
|
|
||||||
|
if (view === 'older') {
|
||||||
|
const older = await fetchDroneRequests({ date_to: yesterday });
|
||||||
|
return [{ label: 'Earlier dated requests', items: older }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'closed') {
|
||||||
|
const [denied, canceled] = await Promise.all([
|
||||||
|
fetchDroneRequests({ status: 'DENIED' }),
|
||||||
|
fetchDroneRequests({ status: 'CANCELED' }),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
{ label: 'Denied requests', items: denied },
|
||||||
|
{ label: 'Canceled requests', items: canceled },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newRequests, approvedToday, approvedUpcoming] = await Promise.all([
|
||||||
|
fetchDroneRequests({ status: 'NEW' }),
|
||||||
|
fetchDroneRequests({ status: 'APPROVED', date_from: today, date_to: today }),
|
||||||
|
fetchDroneRequests({ status: 'APPROVED', date_from: getLocalDateString(addDays(new Date(), 1)) }),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
{ label: 'New requests', items: newRequests },
|
||||||
|
{ label: "Today's approved flights", items: approvedToday },
|
||||||
|
{ label: 'Upcoming approved flights', items: approvedUpcoming },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDroneRequests(params) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value) search.set(key, value);
|
||||||
|
});
|
||||||
|
const url = `/api/v1/drone-requests/${search.toString() ? `?${search.toString()}` : ''}`;
|
||||||
|
const response = await authenticatedFetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to load drone requests');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateString(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRequestList() {
|
function renderRequestList() {
|
||||||
document.getElementById('request-count').textContent = requests.length;
|
document.getElementById('request-count').textContent = requests.length;
|
||||||
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
|
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
|
||||||
const body = document.getElementById('request-list-body');
|
const body = document.getElementById('request-list-body');
|
||||||
if (!requests.length) {
|
if (!requestGroups.length) {
|
||||||
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
|
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.innerHTML = requests.map(req => `
|
body.innerHTML = requestGroups.map(group => `
|
||||||
|
<div class="request-group">${escapeHtml(group.label)} - ${group.items.length}</div>
|
||||||
|
${group.items.length ? group.items.map(renderRequestRow).join('') : '<div class="empty-state">None</div>'}
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequestRow(req) {
|
||||||
|
return `
|
||||||
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
||||||
<div>
|
<div>
|
||||||
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
|
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
|
||||||
@@ -656,7 +726,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="status-pill status-${req.status}">${req.status}</span>
|
<span class="status-pill status-${req.status}">${req.status}</span>
|
||||||
</button>
|
</button>
|
||||||
`).join('');
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectRequest(id, collapseQueue = true) {
|
async function selectRequest(id, collapseQueue = true) {
|
||||||
@@ -753,7 +823,6 @@
|
|||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<select id="status-select" class="lifecycle-control" aria-label="New status">
|
<select id="status-select" class="lifecycle-control" aria-label="New status">
|
||||||
<option value="NEW">NEW</option>
|
<option value="NEW">NEW</option>
|
||||||
<option value="PENDING">PENDING</option>
|
|
||||||
<option value="APPROVED">APPROVED</option>
|
<option value="APPROVED">APPROVED</option>
|
||||||
<option value="DENIED">DENIED</option>
|
<option value="DENIED">DENIED</option>
|
||||||
<option value="CANCELED">CANCELED</option>
|
<option value="CANCELED">CANCELED</option>
|
||||||
|
|||||||
+2
-3
@@ -386,10 +386,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
||||||
|
<!-- Booking QR hidden until the Book Out flow is ready.
|
||||||
<div class="qr-code-container">
|
<div class="qr-code-container">
|
||||||
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
||||||
<div class="qr-label">Book Out</div>
|
<div class="qr-label">Book Out</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
<h1>Flight Information</h1>
|
<h1>Flight Information</h1>
|
||||||
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
||||||
</header>
|
</header>
|
||||||
@@ -893,9 +895,6 @@
|
|||||||
// Initialize Christmas mode
|
// Initialize Christmas mode
|
||||||
initChristmasMode();
|
initChristmasMode();
|
||||||
|
|
||||||
// Load booking QR code
|
|
||||||
generateBookingQR();
|
|
||||||
|
|
||||||
loadArrivals();
|
loadArrivals();
|
||||||
loadDepartures();
|
loadDepartures();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user