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 _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 = [] if clean_lookup: 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() 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], 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 else None if not existing_movement: 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) 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 ) 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" 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