Drone flights and Bulk Logging WIPs

This commit is contained in:
2026-06-19 17:27:33 -04:00
parent 1952b89ecf
commit 78d738b0ee
18 changed files with 2051 additions and 70 deletions
+3 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
api_router = APIRouter()
@@ -12,7 +12,8 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
api_router.include_router(public.router, prefix="/public", tags=["public"])
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
+279
View File
@@ -0,0 +1,279 @@
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_operator_user, get_current_read_user, get_db
from app.core.email import email_service
from app.core.config import settings
from app.core.utils import get_client_ip
from app.crud.crud_drone_request import drone_request as crud_drone_request
from app.crud.crud_journal import journal as crud_journal
from app.models.journal import EntityType
from app.models.ppr import User
from app.schemas.drone_request import (
DroneRequest,
DroneRequestComment,
DroneRequestCreate,
DroneRequestStatus,
DroneRequestStatusUpdate,
DroneRequestUpdate,
)
from app.schemas.journal import JournalEntryResponse
router = APIRouter()
async def _broadcast(request: Request, event_type: str, drone_request: DroneRequest):
if hasattr(request.app.state, "connection_manager"):
await request.app.state.connection_manager.broadcast({
"type": event_type,
"data": {
"id": drone_request.id,
"reference_number": drone_request.reference_number,
"status": drone_request.status.value,
},
})
async def _send_drone_email(drone_request, subject: str, message: str):
await email_service.send_email(
to_email=drone_request.email,
subject=subject,
template_name="drone_request_update.html",
template_vars={
"name": drone_request.operator_name,
"reference_number": drone_request.reference_number,
"status": drone_request.status.value,
"message": message,
"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,
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
},
)
@router.get("/", response_model=List[DroneRequest])
async def get_drone_requests(
skip: int = 0,
limit: int = 100,
status: Optional[DroneRequestStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
return crud_drone_request.get_multi(
db,
skip=skip,
limit=limit,
status=status,
date_from=date_from,
date_to=date_to,
)
@router.post("/public", response_model=DroneRequest)
async def create_public_drone_request(
request: Request,
drone_request_in: DroneRequestCreate,
db: Session = Depends(get_db),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.create(
db,
obj_in=drone_request_in,
created_by="public",
user_ip=client_ip,
submitted_via="PUBLIC",
)
await _broadcast(request, "drone_request_created", drone_request)
await _send_drone_email(
drone_request,
f"Drone flight request received {drone_request.reference_number}",
"We have received your drone flight request. We will email you when the approval status changes or if we need more information.",
)
return drone_request
@router.get("/public/edit/{token}", response_model=DroneRequest)
async def get_drone_request_for_edit(
token: str,
db: Session = Depends(get_db),
):
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")
return drone_request
@router.patch("/public/edit/{token}", response_model=DroneRequest)
async def update_drone_request_public(
token: str,
drone_request_in: DroneRequestUpdate,
request: Request,
db: Session = Depends(get_db),
):
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]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be edited while {drone_request.status.value}",
)
client_ip = get_client_ip(request)
updated_request = crud_drone_request.update(
db,
db_obj=drone_request,
obj_in=drone_request_in,
user="public",
user_ip=client_ip,
)
await _broadcast(request, "drone_request_updated", updated_request)
return updated_request
@router.delete("/public/cancel/{token}", response_model=DroneRequest)
async def cancel_drone_request_public(
token: str,
request: Request,
db: Session = Depends(get_db),
):
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]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
)
client_ip = get_client_ip(request)
cancelled_request = crud_drone_request.update_status(
db,
request_id=drone_request.id,
status=DroneRequestStatus.CANCELED,
comment="Cancelled by operator using secure link",
user="public",
user_ip=client_ip,
)
await _broadcast(request, "drone_request_status_update", cancelled_request)
await _send_drone_email(
cancelled_request,
f"Drone request {cancelled_request.reference_number} CANCELED",
"Your drone flight request has been cancelled.",
)
return cancelled_request
@router.get("/{request_id}", response_model=DroneRequest)
async def get_drone_request(
request_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
drone_request = crud_drone_request.get(db, request_id)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
return drone_request
@router.patch("/{request_id}", response_model=DroneRequest)
async def update_drone_request(
request: Request,
request_id: int,
drone_request_in: DroneRequestUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
db_request = crud_drone_request.get(db, request_id)
if not db_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
client_ip = get_client_ip(request)
drone_request = crud_drone_request.update(
db,
db_obj=db_request,
obj_in=drone_request_in,
user=current_user.username,
user_ip=client_ip,
)
await _broadcast(request, "drone_request_updated", drone_request)
return drone_request
@router.patch("/{request_id}/status", response_model=DroneRequest)
async def update_drone_request_status(
request: Request,
request_id: int,
status_update: DroneRequestStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.update_status(
db,
request_id=request_id,
status=status_update.status,
comment=status_update.comment,
user=current_user.username,
user_ip=client_ip,
)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
await _broadcast(request, "drone_request_status_update", drone_request)
message = status_update.comment or f"Your drone flight request status is now {drone_request.status.value}."
await _send_drone_email(
drone_request,
f"Drone request {drone_request.reference_number} {drone_request.status.value}",
message,
)
return drone_request
@router.post("/{request_id}/comments", response_model=DroneRequest)
async def add_drone_request_comment(
request: Request,
request_id: int,
comment_in: DroneRequestComment,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user),
):
client_ip = get_client_ip(request)
drone_request = crud_drone_request.add_comment(
db,
request_id=request_id,
comment=comment_in.comment,
user=current_user.username,
user_ip=client_ip,
)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
await _broadcast(request, "drone_request_comment_added", drone_request)
if comment_in.email_applicant:
await _send_drone_email(
drone_request,
f"Drone request {drone_request.reference_number} update",
comment_in.comment,
)
return drone_request
@router.get("/{request_id}/journal", response_model=List[JournalEntryResponse])
async def get_drone_request_journal(
request_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user),
):
drone_request = crud_drone_request.get(db, request_id)
if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
return crud_journal.get_entity_journal(db, EntityType.DRONE_REQUEST, request_id)
+64 -52
View File
@@ -143,6 +143,24 @@ def _movement_to_dict(movement: MovementModel) -> dict:
}
def _local_flight_to_dict(local: LocalFlight) -> dict:
return {
"id": local.id,
"aircraft_registration": local.registration,
"aircraft_type": local.type,
"callsign": local.callsign,
"pob": local.pob,
"flight_type": local.flight_type.value if local.flight_type else None,
"status": local.status.value if local.status else None,
"etd": local.etd.isoformat() if local.etd else None,
"takeoff_time": local.takeoff_dt.isoformat() if local.takeoff_dt else None,
"departed_time": local.departed_dt.isoformat() if local.departed_dt else None,
"landing_time": local.landed_dt.isoformat() if local.landed_dt else None,
"circuits": local.circuits,
"notes": local.notes,
}
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
if movements:
movement = movements[0]
@@ -234,7 +252,8 @@ async def get_bulk_movement_context(
entity_type_filter = _strip_entity_type(flight_kind)
pprs = []
if clean_lookup:
local_flights = []
if clean_lookup and flight_kind.upper() != "LOCAL":
pprs = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
or_(
@@ -244,6 +263,20 @@ async def get_bulk_movement_context(
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).limit(10).all()
if clean_lookup and flight_kind.upper() == "LOCAL":
local_flights = db.query(LocalFlight).filter(
_sql_clean_alnum(LocalFlight.registration).like(f"{clean_lookup}%"),
or_(
func.date(LocalFlight.takeoff_dt) == target_date,
func.date(LocalFlight.departed_dt) == target_date,
func.date(LocalFlight.landed_dt) == target_date,
func.date(LocalFlight.etd) == target_date,
func.date(LocalFlight.created_dt) == target_date
),
LocalFlight.flight_type.in_([LocalFlightType.LOCAL, LocalFlightType.CIRCUITS]),
LocalFlight.status != LocalFlightStatus.CANCELLED
).order_by(LocalFlight.takeoff_dt, LocalFlight.etd, LocalFlight.created_dt).limit(10).all()
movements = []
if clean_lookup:
movements = db.query(MovementModel).filter(
@@ -257,6 +290,7 @@ async def get_bulk_movement_context(
return BulkMovementContext(
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
local_flights=[_local_flight_to_dict(local) for local in local_flights],
movements=[_movement_to_dict(movement) for movement in movements],
suggested=_build_suggestion(pprs, movements, flight_kind)
)
@@ -286,8 +320,8 @@ async def bulk_log_movement(
else entry.movement_time
) or entry.movement_time
timestamp = _combine_date_time(entry.movement_date, primary_time)
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id else None
if not existing_movement:
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
if not existing_movement and flight_kind != "LOCAL":
existing_movement = crud_movement.find_daily_match(
db,
entry.movement_date,
@@ -302,56 +336,34 @@ async def bulk_log_movement(
if flight_kind == "LOCAL":
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
local = None
if existing_movement and existing_movement.entity_type == "LOCAL_FLIGHT":
local = db.query(LocalFlight).filter(LocalFlight.id == existing_movement.entity_id).first()
if not local:
local = db.query(LocalFlight).filter(
_sql_clean_alnum(LocalFlight.registration) == clean_lookup,
func.date(LocalFlight.takeoff_dt) == entry.movement_date
).first()
if not local:
local = LocalFlight(
registration=clean_reg,
type=entry.aircraft_type or "",
callsign=entry.callsign,
pob=entry.pob or 1,
flight_type=local_type,
status=LocalFlightStatus.LANDED,
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
circuits=entry.circuits or 0,
notes=entry.notes,
etd=takeoff_dt,
departed_dt=takeoff_dt,
takeoff_dt=takeoff_dt,
landed_dt=landing_dt,
created_by=username,
submitted_via=LocalSubmissionSource.ADMIN
if landing_dt < takeoff_dt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LOCAL landing time cannot be before takeoff time"
)
db.add(local)
db.commit()
db.refresh(local)
action = "created"
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
else:
local.registration = clean_reg
local.type = entry.aircraft_type or local.type
local.callsign = entry.callsign
local.pob = entry.pob or local.pob
local.flight_type = local_type
local.status = LocalFlightStatus.LANDED
local.duration = int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else local.duration
local.circuits = entry.circuits or 0
local.notes = entry.notes
local.etd = takeoff_dt
local.departed_dt = takeoff_dt
local.takeoff_dt = takeoff_dt
local.landed_dt = landing_dt
db.add(local)
db.commit()
db.refresh(local)
action = "updated"
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
local = LocalFlight(
registration=clean_reg,
type=entry.aircraft_type or "",
callsign=entry.callsign,
pob=entry.pob,
flight_type=local_type,
status=LocalFlightStatus.LANDED,
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
circuits=entry.circuits or 0,
notes=entry.notes,
etd=takeoff_dt,
departed_dt=takeoff_dt,
takeoff_dt=takeoff_dt,
landed_dt=landing_dt,
created_by=username,
submitted_via=LocalSubmissionSource.ADMIN
)
db.add(local)
db.commit()
db.refresh(local)
action = "created"
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
takeoff_movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.TAKEOFF,
+178
View File
@@ -0,0 +1,178 @@
from datetime import date, datetime
import secrets
from typing import List, Optional
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.crud.crud_journal import journal as crud_journal
from app.models.drone_request import DroneRequest, DroneRequestStatus
from app.models.journal import EntityType
from app.schemas.drone_request import DroneRequestCreate, DroneRequestUpdate
class CRUDDroneRequest:
def get(self, db: Session, request_id: int) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.id == request_id).first()
def get_by_reference(self, db: Session, reference_number: str) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.reference_number == reference_number).first()
def get_by_public_token(self, db: Session, token: str) -> Optional[DroneRequest]:
return db.query(DroneRequest).filter(DroneRequest.public_token == token).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[DroneRequestStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
) -> List[DroneRequest]:
query = db.query(DroneRequest)
if status:
query = query.filter(DroneRequest.status == status)
if date_from:
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) >= date_from)
if date_to:
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) <= date_to)
return query.order_by(desc(DroneRequest.submitted_at)).offset(skip).limit(limit).all()
def create(
self,
db: Session,
obj_in: DroneRequestCreate,
created_by: str = "public",
user_ip: str = "127.0.0.1",
submitted_via: str = "PUBLIC",
) -> DroneRequest:
reference_number = self._generate_reference(db)
payload = obj_in.dict()
notes = payload.pop("notes", None)
db_obj = DroneRequest(
**payload,
applicant_notes=notes,
reference_number=reference_number,
public_token=secrets.token_urlsafe(64),
status=DroneRequestStatus.NEW,
created_by=created_by,
submitted_ip=user_ip,
submitted_via=submitted_via,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_change(
db,
EntityType.DRONE_REQUEST,
db_obj.id,
f"Drone request {db_obj.reference_number} created",
created_by,
user_ip,
)
return db_obj
def update(
self,
db: Session,
db_obj: DroneRequest,
obj_in: DroneRequestUpdate,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> DroneRequest:
update_data = obj_in.dict(exclude_unset=True)
if "notes" in update_data:
update_data["applicant_notes"] = update_data.pop("notes")
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
for change in changes:
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, change, user, user_ip)
return db_obj
def update_status(
self,
db: Session,
request_id: int,
status: DroneRequestStatus,
comment: Optional[str] = None,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> Optional[DroneRequest]:
db_obj = self.get(db, request_id)
if not db_obj:
return None
old_status = db_obj.status
db_obj.status = status
db_obj.status_changed_at = datetime.utcnow()
db_obj.status_changed_by = user
if comment:
db_obj.operator_comments = comment
db.add(db_obj)
db.commit()
db.refresh(db_obj)
entry = f"Status changed from {old_status.value} to {status.value}"
if comment:
entry = f"{entry}: {comment}"
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, entry, user, user_ip)
return db_obj
def add_comment(
self,
db: Session,
request_id: int,
comment: str,
user: str = "system",
user_ip: str = "127.0.0.1",
) -> Optional[DroneRequest]:
db_obj = self.get(db, request_id)
if not db_obj:
return None
db_obj.operator_comments = comment
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, f"Comment added: {comment}", user, user_ip)
return db_obj
def _generate_reference(self, db: Session) -> str:
prefix = f"DRN-{datetime.utcnow().strftime('%y%m%d')}"
references = db.query(DroneRequest.reference_number).filter(
DroneRequest.reference_number.like(f"{prefix}-%")
).all()
highest_sequence = 0
for (reference_number,) in references:
suffix = reference_number.rsplit("-", 1)[-1]
if suffix.isdigit():
highest_sequence = max(highest_sequence, int(suffix))
for sequence in range(highest_sequence + 1, highest_sequence + 11):
candidate = f"{prefix}-{sequence}"
if not self.get_by_reference(db, candidate):
return candidate
return f"{prefix}-{highest_sequence + 11}"
drone_request = CRUDDroneRequest()
+2 -1
View File
@@ -16,6 +16,7 @@ from app.models.departure import Departure
from app.models.arrival import Arrival
from app.models.circuit import Circuit
from app.models.movement import Movement
from app.models.drone_request import DroneRequest
# Set up logging
logging.basicConfig(level=logging.INFO)
@@ -206,4 +207,4 @@ async def health_check():
app.include_router(api_router, prefix=settings.api_v1_str)
# Make connection manager available to the app
app.state.connection_manager = manager
app.state.connection_manager = manager
+64
View File
@@ -0,0 +1,64 @@
from enum import Enum
from sqlalchemy import BigInteger, Boolean, Column, Date, DateTime, Enum as SQLEnum, Float, Index, Integer, JSON, String, Text
from sqlalchemy.sql import func
from app.db.session import Base
class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
class DroneRequest(Base):
__tablename__ = "drone_requests"
id = Column(BigInteger, primary_key=True, autoincrement=True)
reference_number = Column(String(24), nullable=False, unique=True, index=True)
public_token = Column(String(128), nullable=True, unique=True, index=True)
status = Column(SQLEnum(DroneRequestStatus), nullable=False, default=DroneRequestStatus.NEW, index=True)
operator_name = Column(String(128), nullable=False, index=True)
operator_id = Column(String(64), nullable=True)
flyer_name = Column(String(128), nullable=True)
flyer_id = Column(String(64), nullable=True)
email = Column(String(128), nullable=False, index=True)
phone = Column(String(32), nullable=True)
flight_date = Column(Date, nullable=True, index=True)
estimated_takeoff_time = Column(String(8), nullable=True)
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)
location_description = Column(Text, nullable=True)
location_latitude = Column(Float, nullable=False)
location_longitude = Column(Float, nullable=False)
location_inside_frz = Column(Boolean, nullable=True)
prototype_overlay = Column(JSON, nullable=True)
applicant_notes = Column(Text, nullable=True)
operator_comments = Column(Text, nullable=True)
submitted_via = Column(String(32), nullable=False, default="PUBLIC")
submitted_ip = Column(String(45), nullable=True)
created_by = Column(String(50), nullable=True, index=True)
submitted_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
status_changed_at = Column(DateTime, nullable=True)
status_changed_by = Column(String(50), nullable=True)
__table_args__ = (
Index("idx_drone_status_takeoff", "status", "estimated_takeoff_at"),
)
@property
def notes(self):
return self.applicant_notes
+1
View File
@@ -7,6 +7,7 @@ from app.db.session import Base
class EntityType(str, PyEnum):
"""Entity types that can have journal entries"""
PPR = "PPR"
DRONE_REQUEST = "DRONE_REQUEST"
LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
+1 -1
View File
@@ -35,7 +35,7 @@ class LocalFlight(Base):
registration = Column(String(16), nullable=False, index=True)
type = Column(String(32), nullable=False) # Aircraft type
callsign = Column(String(16), nullable=True)
pob = Column(Integer, nullable=False) # Persons on board
pob = Column(Integer, nullable=True) # Persons on board may be unknown for post-event logging
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes
+106
View File
@@ -0,0 +1,106 @@
from datetime import date, datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, EmailStr, Field, validator
class DroneRequestStatus(str, Enum):
NEW = "NEW"
APPROVED = "APPROVED"
DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED"
class DroneRequestBase(BaseModel):
operator_name: str = Field(..., max_length=128)
operator_id: Optional[str] = Field(None, max_length=64)
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)
location_description: Optional[str] = None
location_latitude: float = Field(..., ge=-90, le=90)
location_longitude: float = Field(..., ge=-180, le=180)
location_inside_frz: Optional[bool] = None
flyer_name: Optional[str] = Field(None, max_length=128)
flyer_id: Optional[str] = Field(None, max_length=64)
email: EmailStr
phone: Optional[str] = Field(None, max_length=32)
notes: Optional[str] = None
estimated_takeoff_at: datetime
estimated_completion_at: datetime
prototype_overlay: Optional[dict[str, Any]] = None
@validator("operator_name")
def validate_operator_name(cls, value):
value = value.strip()
if not value:
raise ValueError("Operator name is required")
return value
@validator("location_inside_frz", pre=True)
def parse_inside_frz(cls, value):
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"yes", "true", "1", "y"}:
return True
if normalized in {"no", "false", "0", "n"}:
return False
return value
class DroneRequestCreate(DroneRequestBase):
pass
class DroneRequestUpdate(BaseModel):
operator_name: Optional[str] = Field(None, max_length=128)
operator_id: Optional[str] = Field(None, max_length=64)
flyer_name: Optional[str] = Field(None, max_length=128)
flyer_id: Optional[str] = Field(None, max_length=64)
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=32)
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)
estimated_takeoff_at: Optional[datetime] = None
estimated_completion_at: Optional[datetime] = None
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
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)
location_inside_frz: Optional[bool] = None
notes: Optional[str] = None
prototype_overlay: Optional[dict[str, Any]] = None
operator_comments: Optional[str] = None
class DroneRequestStatusUpdate(BaseModel):
status: DroneRequestStatus
comment: Optional[str] = None
class DroneRequestComment(BaseModel):
comment: str = Field(..., min_length=1)
email_applicant: bool = True
class DroneRequest(DroneRequestBase):
id: int
reference_number: str
status: DroneRequestStatus
operator_comments: Optional[str] = None
submitted_via: str
submitted_ip: Optional[str] = None
created_by: Optional[str] = None
submitted_at: datetime
updated_at: datetime
status_changed_at: Optional[datetime] = None
status_changed_by: Optional[str] = None
class Config:
from_attributes = True
+2 -2
View File
@@ -32,7 +32,7 @@ class LocalFlightBase(BaseModel):
registration: str
type: Optional[str] = None # Aircraft type - optional, can be looked up later
callsign: Optional[str] = None
pob: int
pob: Optional[int] = None
flight_type: LocalFlightType
duration: Optional[int] = 45 # Duration in minutes, default 45
etd: Optional[datetime] = None # Estimated Time of Departure
@@ -105,4 +105,4 @@ class LocalFlightInDBBase(LocalFlightBase):
class LocalFlight(LocalFlightInDBBase):
pass
pass
+2 -1
View File
@@ -1,5 +1,5 @@
from typing import List, Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
from datetime import date, datetime
from app.models.movement import MovementType
@@ -52,6 +52,7 @@ class BulkMovementLog(BaseModel):
class BulkMovementContext(BaseModel):
pprs: List[dict]
local_flights: List[dict] = Field(default_factory=list)
movements: List[dict]
suggested: dict
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Drone Flight Request Update</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
<h2>Drone Flight Request Update</h2>
<p>Hello {{ name }},</p>
<p>{{ message }}</p>
<table cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr><td><strong>Reference</strong></td><td>{{ reference_number }}</td></tr>
<tr><td><strong>Status</strong></td><td>{{ status }}</td></tr>
<tr><td><strong>Takeoff</strong></td><td>{{ takeoff_time }}</td></tr>
<tr><td><strong>Completion</strong></td><td>{{ completion_time }}</td></tr>
<tr><td><strong>Location</strong></td><td>{{ location }}</td></tr>
<tr><td><strong>Max elevation</strong></td><td>{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
</table>
{% if edit_url %}
<p>You can <a href="{{ edit_url }}">view, update, or cancel your drone request</a> using this secure link.</p>
{% endif %}
<p>Please quote your reference number in any replies.</p>
</body>
</html>