Files
ppr-ng/backend/app/api/endpoints/movements.py
T

648 lines
26 KiB
Python

from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from datetime import date, datetime, time
from app.api.deps import get_db, get_current_operator_user, get_current_read_user
from app.crud.crud_movement import movement as crud_movement
from app.crud.crud_journal import journal as crud_journal
from app.schemas.movement import BulkMovementContext, BulkMovementLog, BulkMovementResult, Movement, MovementCreate
from app.models.ppr import User
from app.models.arrival import Arrival, ArrivalStatus, SubmissionSource as ArrivalSubmissionSource
from app.models.circuit import Circuit
from app.models.departure import Departure, DepartureStatus, SubmissionSource as DepartureSubmissionSource
from app.models.journal import EntityType
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType, SubmissionSource as LocalSubmissionSource
from app.models.movement import Movement as MovementModel, MovementType
from app.models.overflight import Overflight, OverflightStatus
from app.models.ppr import PPRRecord, PPRStatus
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Movement])
async def get_movements(
skip: int = 0,
limit: int = 100,
movement_type: Optional[MovementType] = None,
aircraft_registration: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
entity_type: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get movement records with optional filtering"""
movements = crud_movement.get_multi(
db, skip=skip, limit=limit, movement_type=movement_type,
aircraft_registration=aircraft_registration, date_from=date_from,
date_to=date_to, entity_type=entity_type
)
return movements
def _clean_reg(registration: str) -> str:
return (registration or "").strip().upper()
def _clean_alnum(value: str) -> str:
return "".join(char for char in (value or "").upper() if char.isalnum())
def _sql_clean_alnum(column):
return func.upper(func.replace(func.replace(column, "-", ""), " ", ""))
def _combine_date_time(movement_date: date, movement_time: str) -> datetime:
if not movement_time:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A time is required for this strip"
)
try:
parsed_time = time.fromisoformat(movement_time)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="movement_time must be HH:MM or HH:MM:SS"
)
return datetime.combine(movement_date, parsed_time)
def _kind_to_movement_type(flight_kind: str) -> MovementType:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return MovementType.LANDING
if kind == "DEPARTURE":
return MovementType.TAKEOFF
if kind == "LOCAL":
return MovementType.TAKEOFF
if kind == "OVERFLIGHT":
return MovementType.OVERFLIGHT
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="flight_kind must be ARRIVAL, DEPARTURE, LOCAL or OVERFLIGHT"
)
def _strip_entity_type(flight_kind: str) -> Optional[str]:
kind = (flight_kind or "").strip().upper()
return {
"LOCAL": "LOCAL_FLIGHT",
"OVERFLIGHT": "OVERFLIGHT",
}.get(kind)
def _compatible_entity_types(flight_kind: str) -> List[str]:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return ["PPR", "ARRIVAL"]
if kind == "DEPARTURE":
return ["PPR", "DEPARTURE"]
if kind == "LOCAL":
return ["LOCAL_FLIGHT"]
if kind == "OVERFLIGHT":
return ["OVERFLIGHT"]
return []
def _ppr_to_dict(ppr: PPRRecord) -> dict:
return {
"id": ppr.id,
"status": ppr.status.value,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"eta": ppr.eta.isoformat() if ppr.eta else None,
"etd": ppr.etd.isoformat() if ppr.etd else None,
"pob_in": ppr.pob_in,
"pob_out": ppr.pob_out,
"notes": ppr.notes,
}
def _movement_to_dict(movement: MovementModel) -> dict:
return {
"id": movement.id,
"movement_type": movement.movement_type.value,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"timestamp": movement.timestamp.isoformat() if movement.timestamp else None,
"entity_type": movement.entity_type,
"entity_id": movement.entity_id,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
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]
return {
"source": "movement",
"movement_id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"movement_time": movement.timestamp.strftime("%H:%M") if movement.timestamp else None,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
if pprs:
ppr = pprs[0]
is_arrival = flight_kind.upper() == "ARRIVAL"
timestamp = ppr.eta if is_arrival else ppr.etd
return {
"source": "ppr",
"ppr_id": ppr.id,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"movement_time": timestamp.strftime("%H:%M") if timestamp else None,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"pob": ppr.pob_in if is_arrival else (ppr.pob_out or ppr.pob_in),
"notes": ppr.notes,
}
return {}
def _movement_for_entity(
db: Session,
entity_type: str,
entity_id: int,
movement_type: MovementType
) -> Optional[MovementModel]:
return db.query(MovementModel).filter(
MovementModel.entity_type == entity_type,
MovementModel.entity_id == entity_id,
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp).first()
def _create_or_update_movement(db: Session, movement_data: MovementCreate) -> MovementModel:
existing = _movement_for_entity(
db,
movement_data.entity_type,
movement_data.entity_id,
movement_data.movement_type
)
if existing:
return crud_movement.update(db, existing, movement_data)
return crud_movement.create(db, movement_data)
def _clear_local_circuit_details(
db: Session,
local_flight: LocalFlight
) -> None:
db.query(Circuit).filter(Circuit.local_flight_id == local_flight.id).delete()
db.query(MovementModel).filter(
MovementModel.entity_type == "LOCAL_FLIGHT",
MovementModel.entity_id == local_flight.id,
MovementModel.movement_type == MovementType.TOUCH_AND_GO
).delete()
db.commit()
@router.get("/bulk-context", response_model=BulkMovementContext)
async def get_bulk_movement_context(
target_date: date,
aircraft_registration: str,
flight_kind: str = "ARRIVAL",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Find same-day PPRs and movements that may match a bulk entry strip."""
clean_reg = _clean_reg(aircraft_registration)
clean_lookup = _clean_alnum(aircraft_registration)
movement_type = _kind_to_movement_type(flight_kind)
entity_type_filter = _strip_entity_type(flight_kind)
pprs = []
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_(
func.date(PPRRecord.eta) == target_date,
func.date(PPRRecord.etd) == target_date
),
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(
func.date(MovementModel.timestamp) == target_date,
_sql_clean_alnum(MovementModel.aircraft_registration).like(f"{clean_lookup}%"),
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp.desc()).limit(10).all()
compatible_types = _compatible_entity_types(flight_kind)
if compatible_types:
movements = [movement for movement in movements if movement.entity_type in compatible_types]
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)
)
@router.post("/bulk-log", response_model=BulkMovementResult)
async def bulk_log_movement(
request: Request,
entry: BulkMovementLog,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create or update one same-day movement from a paper strip."""
client_ip = get_client_ip(request)
username = current_user.username
clean_reg = _clean_reg(entry.aircraft_registration)
clean_lookup = _clean_alnum(entry.aircraft_registration)
if not clean_reg:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aircraft registration is required")
movement_type = _kind_to_movement_type(entry.flight_kind)
flight_kind = entry.flight_kind.strip().upper()
primary_time = (
entry.landing_time if flight_kind == "ARRIVAL"
else entry.takeoff_time if flight_kind in ("DEPARTURE", "LOCAL")
else entry.contact_time if flight_kind == "OVERFLIGHT"
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 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,
clean_reg,
movement_type,
entity_type=_strip_entity_type(flight_kind),
entity_types=None if _strip_entity_type(flight_kind) else _compatible_entity_types(flight_kind)
)
if existing_movement and existing_movement.entity_type not in _compatible_entity_types(flight_kind):
existing_movement = None
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)
if landing_dt < takeoff_dt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LOCAL landing time cannot be before takeoff time"
)
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,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=takeoff_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_create_or_update_movement(db, MovementCreate(
movement_type=MovementType.LANDING,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=landing_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_clear_local_circuit_details(db, local)
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, f"Bulk local strip {action}: takeoff {takeoff_dt.strftime('%H:%M')}, landing {landing_dt.strftime('%H:%M')}, circuits {entry.circuits or 0}", username, client_ip)
return BulkMovementResult(action=action, movement=takeoff_movement, entity_type="LOCAL_FLIGHT", entity_id=local.id, message=f"Local strip {action} for {clean_reg}")
if flight_kind == "OVERFLIGHT":
contact_dt = _combine_date_time(entry.movement_date, entry.contact_time)
qsy_dt = _combine_date_time(entry.movement_date, entry.qsy_time) if entry.qsy_time else None
overflight = None
if existing_movement and existing_movement.entity_type == "OVERFLIGHT":
overflight = db.query(Overflight).filter(Overflight.id == existing_movement.entity_id).first()
if not overflight:
overflight = db.query(Overflight).filter(
_sql_clean_alnum(Overflight.registration) == clean_lookup,
func.date(Overflight.call_dt) == entry.movement_date
).first()
if not overflight:
overflight = Overflight(
registration=clean_reg,
pob=entry.pob,
type=entry.aircraft_type,
departure_airfield=entry.from_location,
destination_airfield=entry.to_location,
status=OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE,
call_dt=contact_dt,
qsy_dt=qsy_dt,
notes=entry.notes,
created_by=username
)
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "created"
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, "Overflight strip created from bulk flight log", username, client_ip)
else:
overflight.registration = clean_reg
overflight.pob = entry.pob
overflight.type = entry.aircraft_type
overflight.departure_airfield = entry.from_location
overflight.destination_airfield = entry.to_location
overflight.status = OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE
overflight.call_dt = contact_dt
overflight.qsy_dt = qsy_dt
overflight.notes = entry.notes
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "updated"
movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.OVERFLIGHT,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=contact_dt,
entity_type="OVERFLIGHT",
entity_id=overflight.id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, f"Bulk overflight strip {action}: contact {contact_dt.strftime('%H:%M')}" + (f", QSY {qsy_dt.strftime('%H:%M')}" if qsy_dt else ""), username, client_ip)
return BulkMovementResult(action=action, movement=movement, entity_type="OVERFLIGHT", entity_id=overflight.id, message=f"Overflight strip {action} for {clean_reg}")
ppr = None
if entry.ppr_id:
ppr = db.query(PPRRecord).filter(PPRRecord.id == entry.ppr_id).first()
if not ppr and existing_movement and existing_movement.entity_type == "PPR":
ppr = db.query(PPRRecord).filter(PPRRecord.id == existing_movement.entity_id).first()
if not ppr:
ppr = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg) == clean_lookup,
or_(
func.date(PPRRecord.eta) == entry.movement_date,
func.date(PPRRecord.etd) == entry.movement_date
),
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).first()
entity_type = existing_movement.entity_type if existing_movement else None
entity_id = existing_movement.entity_id if existing_movement else None
if not entity_type:
if ppr:
entity_type = "PPR"
entity_id = ppr.id
elif movement_type == MovementType.LANDING:
arrival = Arrival(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
in_from=entry.from_location or "ZZZZ",
status=ArrivalStatus.LANDED,
notes=entry.notes,
eta=timestamp,
landed_dt=timestamp,
created_by=username,
submitted_via=ArrivalSubmissionSource.ADMIN
)
db.add(arrival)
db.commit()
db.refresh(arrival)
entity_type = "ARRIVAL"
entity_id = arrival.id
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, "Arrival created from bulk flight log", username, client_ip)
else:
departure = Departure(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
out_to=entry.to_location or "ZZZZ",
status=DepartureStatus.DEPARTED,
notes=entry.notes,
etd=timestamp,
takeoff_dt=timestamp,
departed_dt=timestamp,
created_by=username,
submitted_via=DepartureSubmissionSource.ADMIN
)
db.add(departure)
db.commit()
db.refresh(departure)
entity_type = "DEPARTURE"
entity_id = departure.id
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, "Departure created from bulk flight log", username, client_ip)
movement_data = MovementCreate(
movement_type=movement_type,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=timestamp,
entity_type=entity_type,
entity_id=entity_id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
)
if existing_movement:
movement = crud_movement.update(db, existing_movement, movement_data)
action = "updated"
else:
movement = crud_movement.create(db, movement_data)
action = "created"
if entity_type == "PPR" and ppr:
ppr.ac_type = entry.aircraft_type or ppr.ac_type
ppr.ac_call = entry.callsign or ppr.ac_call
if movement_type == MovementType.LANDING:
ppr.in_from = entry.from_location or ppr.in_from
ppr.pob_in = entry.pob or ppr.pob_in
ppr.landed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED, PPRStatus.DEPARTED):
ppr.status = PPRStatus.LANDED
else:
ppr.out_to = entry.to_location or ppr.out_to
ppr.pob_out = entry.pob or ppr.pob_out
ppr.departed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
ppr.status = PPRStatus.DEPARTED
if entry.notes:
ppr.notes = entry.notes
db.add(ppr)
db.commit()
crud_journal.log_change(
db,
EntityType.PPR,
ppr.id,
f"Bulk flight log {action}: {movement_type.value} at {timestamp.strftime('%Y-%m-%d %H:%M')}",
username,
client_ip
)
elif entity_type == "ARRIVAL":
arrival = db.query(Arrival).filter(Arrival.id == entity_id).first()
if arrival:
arrival.registration = clean_reg
arrival.type = entry.aircraft_type
arrival.callsign = entry.callsign
arrival.pob = entry.pob or arrival.pob
arrival.in_from = entry.from_location or arrival.in_from
arrival.eta = timestamp
arrival.landed_dt = timestamp
arrival.status = ArrivalStatus.LANDED
arrival.notes = entry.notes
db.add(arrival)
db.commit()
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, f"Bulk flight log {action}: landing at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
elif entity_type == "DEPARTURE":
departure = db.query(Departure).filter(Departure.id == entity_id).first()
if departure:
departure.registration = clean_reg
departure.type = entry.aircraft_type
departure.callsign = entry.callsign
departure.pob = entry.pob or departure.pob
departure.out_to = entry.to_location or departure.out_to
departure.etd = timestamp
departure.takeoff_dt = timestamp
departure.departed_dt = timestamp
departure.status = DepartureStatus.DEPARTED
departure.notes = entry.notes
db.add(departure)
db.commit()
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, f"Bulk flight log {action}: takeoff at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "movement_bulk_logged",
"data": {
"id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"movement_type": movement.movement_type.value,
"action": action
}
})
return BulkMovementResult(
action=action,
movement=movement,
entity_type=entity_type,
entity_id=entity_id,
message=f"Movement {action} for {clean_reg}"
)
@router.get("/{movement_id}", response_model=Movement)
async def get_movement(
movement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific movement record"""
movement = crud_movement.get(db, movement_id=movement_id)
if not movement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Movement record not found"
)
return movement