Drone Flight Request
+Loading request...
+ +diff --git a/backend/alembic/versions/008_ppr_strip_acknowledgement.py b/backend/alembic/versions/008_ppr_strip_acknowledgement.py index da99980..2a78cf7 100644 --- a/backend/alembic/versions/008_ppr_strip_acknowledgement.py +++ b/backend/alembic/versions/008_ppr_strip_acknowledgement.py @@ -17,8 +17,11 @@ depends_on = None def upgrade() -> None: op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True)) op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True)) + op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=True) def downgrade() -> None: + op.execute("UPDATE local_flights SET pob = 1 WHERE pob IS NULL") + op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=False) op.drop_column('submitted', 'acknowledged_by') op.drop_column('submitted', 'acknowledged_dt') diff --git a/backend/alembic/versions/009_drone_requests.py b/backend/alembic/versions/009_drone_requests.py new file mode 100644 index 0000000..ac9fa21 --- /dev/null +++ b/backend/alembic/versions/009_drone_requests.py @@ -0,0 +1,90 @@ +"""Add drone flight requests + +Revision ID: 009_drone_requests +Revises: 008_ppr_strip_acknowledgement +Create Date: 2026-06-19 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '009_drone_requests' +down_revision = '008_ppr_strip_acknowledgement' +branch_labels = None +depends_on = None + + +drone_status = sa.Enum( + 'NEW', + 'APPROVED', + 'DENIED', + 'PENDING', + 'CANCELED', + 'INFLIGHT', + 'COMPLETED', + name='dronerequeststatus', +) + + +def upgrade() -> None: + op.create_table( + 'drone_requests', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('reference_number', sa.String(length=24), nullable=False), + sa.Column('public_token', sa.String(length=128), nullable=True), + sa.Column('status', drone_status, nullable=False), + sa.Column('operator_name', sa.String(length=128), nullable=False), + sa.Column('operator_id', sa.String(length=64), nullable=True), + sa.Column('flyer_name', sa.String(length=128), nullable=True), + sa.Column('flyer_id', sa.String(length=64), nullable=True), + sa.Column('email', sa.String(length=128), nullable=False), + sa.Column('phone', sa.String(length=32), nullable=True), + sa.Column('flight_date', sa.Date(), nullable=True), + sa.Column('estimated_takeoff_time', sa.String(length=8), nullable=True), + sa.Column('estimated_completion_time', sa.String(length=8), nullable=True), + sa.Column('estimated_takeoff_at', sa.DateTime(), nullable=False), + sa.Column('estimated_completion_at', sa.DateTime(), nullable=False), + sa.Column('maximum_elevation_ft_amsl', sa.Integer(), nullable=False), + sa.Column('location_description', sa.Text(), nullable=True), + sa.Column('location_latitude', sa.Float(), nullable=False), + sa.Column('location_longitude', sa.Float(), nullable=False), + sa.Column('location_inside_frz', sa.Boolean(), nullable=True), + sa.Column('prototype_overlay', sa.JSON(), nullable=True), + sa.Column('applicant_notes', sa.Text(), nullable=True), + sa.Column('operator_comments', sa.Text(), nullable=True), + sa.Column('submitted_via', sa.String(length=32), nullable=False, server_default='PUBLIC'), + sa.Column('submitted_ip', sa.String(length=45), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('submitted_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('status_changed_at', sa.DateTime(), nullable=True), + sa.Column('status_changed_by', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('reference_number'), + ) + op.create_index(op.f('ix_drone_requests_created_by'), 'drone_requests', ['created_by'], unique=False) + op.create_index(op.f('ix_drone_requests_email'), 'drone_requests', ['email'], unique=False) + op.create_index(op.f('ix_drone_requests_estimated_completion_at'), 'drone_requests', ['estimated_completion_at'], unique=False) + op.create_index(op.f('ix_drone_requests_estimated_takeoff_at'), 'drone_requests', ['estimated_takeoff_at'], unique=False) + op.create_index(op.f('ix_drone_requests_flight_date'), 'drone_requests', ['flight_date'], unique=False) + op.create_index(op.f('ix_drone_requests_operator_name'), 'drone_requests', ['operator_name'], unique=False) + op.create_index(op.f('ix_drone_requests_public_token'), 'drone_requests', ['public_token'], unique=True) + op.create_index(op.f('ix_drone_requests_reference_number'), 'drone_requests', ['reference_number'], unique=True) + op.create_index(op.f('ix_drone_requests_status'), 'drone_requests', ['status'], unique=False) + op.create_index(op.f('ix_drone_requests_submitted_at'), 'drone_requests', ['submitted_at'], unique=False) + op.create_index('idx_drone_status_takeoff', 'drone_requests', ['status', 'estimated_takeoff_at'], unique=False) + + +def downgrade() -> None: + op.drop_index('idx_drone_status_takeoff', table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_submitted_at'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_status'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_reference_number'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_public_token'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_operator_name'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_flight_date'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_estimated_takeoff_at'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_estimated_completion_at'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_email'), table_name='drone_requests') + op.drop_index(op.f('ix_drone_requests_created_by'), table_name='drone_requests') + op.drop_table('drone_requests') diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 4a8df04..ce08c1e 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -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"]) \ No newline at end of file +api_router.include_router(airport.router, prefix="/airport", tags=["airport"]) diff --git a/backend/app/api/endpoints/drone_requests.py b/backend/app/api/endpoints/drone_requests.py new file mode 100644 index 0000000..61331e3 --- /dev/null +++ b/backend/app/api/endpoints/drone_requests.py @@ -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) diff --git a/backend/app/api/endpoints/movements.py b/backend/app/api/endpoints/movements.py index 8bacf2a..b98f193 100644 --- a/backend/app/api/endpoints/movements.py +++ b/backend/app/api/endpoints/movements.py @@ -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, diff --git a/backend/app/crud/crud_drone_request.py b/backend/app/crud/crud_drone_request.py new file mode 100644 index 0000000..dedaffc --- /dev/null +++ b/backend/app/crud/crud_drone_request.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 0189151..039660c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 \ No newline at end of file +app.state.connection_manager = manager diff --git a/backend/app/models/drone_request.py b/backend/app/models/drone_request.py new file mode 100644 index 0000000..72f7d1d --- /dev/null +++ b/backend/app/models/drone_request.py @@ -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 diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py index 279c001..a5dc2f0 100644 --- a/backend/app/models/journal.py +++ b/backend/app/models/journal.py @@ -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" diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py index 7945eb4..0e02cce 100644 --- a/backend/app/models/local_flight.py +++ b/backend/app/models/local_flight.py @@ -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 diff --git a/backend/app/schemas/drone_request.py b/backend/app/schemas/drone_request.py new file mode 100644 index 0000000..eb11cd1 --- /dev/null +++ b/backend/app/schemas/drone_request.py @@ -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 diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py index 9cc94d0..747063d 100644 --- a/backend/app/schemas/local_flight.py +++ b/backend/app/schemas/local_flight.py @@ -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 \ No newline at end of file + pass diff --git a/backend/app/schemas/movement.py b/backend/app/schemas/movement.py index 962f2c3..e0c9199 100644 --- a/backend/app/schemas/movement.py +++ b/backend/app/schemas/movement.py @@ -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 diff --git a/backend/app/templates/drone_request_update.html b/backend/app/templates/drone_request_update.html new file mode 100644 index 0000000..d971bb8 --- /dev/null +++ b/backend/app/templates/drone_request_update.html @@ -0,0 +1,27 @@ + + +
+ +Hello {{ name }},
+{{ message }}
+ +| Reference | {{ reference_number }} |
| Status | {{ status }} |
| Takeoff | {{ takeoff_time }} |
| Completion | {{ completion_time }} |
| Location | {{ location }} |
| Max elevation | {{ maximum_elevation_ft_amsl }} ft AMSL |
You can view, update, or cancel your drone request using this secure link.
+ {% endif %} + +Please quote your reference number in any replies.
+ + diff --git a/web/admin.html b/web/admin.html index af5f385..651e53a 100644 --- a/web/admin.html +++ b/web/admin.html @@ -32,6 +32,7 @@ ๐๏ธ ATC View ๐ Reports ๐ Movements + Drone Requests ๐งพ Bulk Flight Log ๐ Journal Log โ๏ธ User Aircraft diff --git a/web/bulk-log.html b/web/bulk-log.html index 1f91d18..e3058bb 100644 --- a/web/bulk-log.html +++ b/web/bulk-log.html @@ -266,6 +266,85 @@ font-size: 0.85rem; } + .match-strips { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-top: 0.55rem; + } + + .match-strip-preview { + background: transparent; + border: 0; + display: block; + padding: 0; + text-align: left; + } + + .match-strip { + display: inline-block; + min-width: 760px; + padding: 0.75rem; + } + + .match-strip .bulk-field { + min-width: 0; + } + + .match-strip .strip-value { + background: rgba(255,255,255,0.35); + border-radius: 4px; + color: #111; + font-size: 0.95rem; + font-weight: 700; + min-height: 2rem; + padding: 0.45rem 0.55rem; + text-transform: uppercase; + } + + .match-strip .callsign-preview { + align-items: center; + justify-content: center; + } + + .match-strip .callsign-preview label { + display: none; + } + + .match-strip .callsign-preview .strip-value { + align-items: center; + background: transparent; + display: flex; + font-size: 1.2rem; + justify-content: center; + min-height: 100%; + padding: 0; + text-align: center; + } + + .match-strip.strip-kind-local { + padding: 0; + } + + .match-strip.strip-kind-local .strip-value { + background: rgba(255,255,255,0.28); + border-radius: 0; + min-height: 0; + padding: 0.2rem; + } + + .match-strip.strip-kind-local .strip-registration .strip-value { + background: rgba(255,255,255,0.55); + outline: 2px solid rgba(0,0,0,0.2); + width: 100%; + } + + .match-strip.strip-kind-local .strip-registration.callsign-preview .strip-value { + background: transparent; + font-size: 1.35rem; + outline: 0; + } + .movement-table table { min-width: 1050px; } @@ -640,6 +719,10 @@ function applySuggestion(suggestion) { if (!suggestion.source) return; + if (document.getElementById('flight-kind').value === 'LOCAL') { + fillIfEmpty('aircraft-type', suggestion.aircraft_type); + return; + } if (suggestion.movement_id) document.getElementById('matched-movement-id').value = suggestion.movement_id; if (suggestion.ppr_id) document.getElementById('matched-ppr-id').value = suggestion.ppr_id; fillSuggestedTime(suggestion.movement_time); @@ -675,30 +758,138 @@ function renderContext(context) { const panel = document.getElementById('context-panel'); const pprs = context.pprs || []; + const localFlights = context.local_flights || []; const movements = context.movements || []; - if (!pprs.length && !movements.length) { + if (!pprs.length && !localFlights.length) { hideContext(); return; } - panel.className = `context-panel ${movements.length ? 'warning' : ''}`; + panel.className = 'context-panel'; const parts = []; - if (movements.length) { - parts.push(`Existing movement found. Saving will update it instead of adding another.`); - parts.push(movements.map(m => `${formatTime(m.timestamp)} ${m.movement_type} #${m.id}`).join('')); - } if (pprs.length) { parts.push(`Matching PPR${pprs.length > 1 ? 's' : ''}:`); - parts.push(pprs.map(p => { - const encoded = encodeURIComponent(JSON.stringify(p)); - return ``; - }).join('')); + parts.push(renderPPRMatchStrips(pprs)); + } + if (localFlights.length) { + parts.push(`Existing local flight${localFlights.length > 1 ? 's' : ''}:`); + parts.push(renderLocalFlightMatchStrips(localFlights)); } panel.innerHTML = parts.join('Loading request...
+ +