diff --git a/backend/alembic/versions/008_ppr_strip_acknowledgement.py b/backend/alembic/versions/008_ppr_strip_acknowledgement.py new file mode 100644 index 0000000..da99980 --- /dev/null +++ b/backend/alembic/versions/008_ppr_strip_acknowledgement.py @@ -0,0 +1,24 @@ +"""Add PPR paper strip acknowledgement fields + +Revision ID: 008_ppr_strip_acknowledgement +Revises: 007_ppr_activated_status +Create Date: 2026-06-15 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '008_ppr_strip_acknowledgement' +down_revision = '007_ppr_activated_status' +branch_labels = None +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)) + + +def downgrade() -> None: + op.drop_column('submitted', 'acknowledged_by') + op.drop_column('submitted', 'acknowledged_dt') diff --git a/backend/app/api/endpoints/movements.py b/backend/app/api/endpoints/movements.py index 0d27e2e..8bacf2a 100644 --- a/backend/app/api/endpoints/movements.py +++ b/backend/app/api/endpoints/movements.py @@ -1,12 +1,22 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session -from datetime import date -from app.api.deps import get_db, get_current_read_user +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.schemas.movement import 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.movement import MovementType +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() @@ -32,6 +42,583 @@ async def get_movements( 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, @@ -45,4 +632,4 @@ async def get_movement( status_code=status.HTTP_404_NOT_FOUND, detail="Movement record not found" ) - return movement \ No newline at end of file + return movement diff --git a/backend/app/api/endpoints/pprs.py b/backend/app/api/endpoints/pprs.py index badf967..eb28cda 100644 --- a/backend/app/api/endpoints/pprs.py +++ b/backend/app/api/endpoints/pprs.py @@ -239,6 +239,41 @@ async def update_ppr_status( return ppr +@router.post("/{ppr_id}/acknowledge", response_model=PPR) +async def acknowledge_ppr_strip( + request: Request, + ppr_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_operator_user) +): + """Acknowledge that the operator has created the paper strip for a PPR.""" + client_ip = get_client_ip(request) + ppr = crud_ppr.acknowledge_strip( + db, + ppr_id=ppr_id, + user=current_user.username, + user_ip=client_ip + ) + if not ppr: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="PPR record not found" + ) + + if hasattr(request.app.state, 'connection_manager'): + await request.app.state.connection_manager.broadcast({ + "type": "ppr_acknowledged", + "data": { + "id": ppr.id, + "ac_reg": ppr.ac_reg, + "acknowledged_dt": ppr.acknowledged_dt.isoformat() if ppr.acknowledged_dt else None, + "acknowledged_by": ppr.acknowledged_by + } + }) + + return ppr + + @router.delete("/{ppr_id}", response_model=PPR) async def delete_ppr( request: Request, @@ -451,4 +486,4 @@ async def activate_ppr( f"PPR activated: arrival #{new_arrival.id} created" + (f", departure #{new_departure.id} queued (will appear when aircraft lands)" if new_departure else "") ) - } \ No newline at end of file + } diff --git a/backend/app/crud/crud_movement.py b/backend/app/crud/crud_movement.py index a3a2821..90e0abc 100644 --- a/backend/app/crud/crud_movement.py +++ b/backend/app/crud/crud_movement.py @@ -47,6 +47,37 @@ class CRUDMovement: db.refresh(db_obj) return db_obj + def update(self, db: Session, db_obj: Movement, obj_in: MovementCreate) -> Movement: + update_data = obj_in.dict() + for field, value in update_data.items(): + setattr(db_obj, field, value) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def find_daily_match( + self, + db: Session, + target_date: date, + aircraft_registration: str, + movement_type: MovementType, + entity_type: Optional[str] = None, + entity_types: Optional[List[str]] = None + ) -> Optional[Movement]: + clean_reg = "".join(char for char in aircraft_registration.upper() if char.isalnum()) + clean_column = func.upper(func.replace(func.replace(Movement.aircraft_registration, "-", ""), " ", "")) + query = db.query(Movement).filter( + func.date(Movement.timestamp) == target_date, + clean_column == clean_reg, + Movement.movement_type == movement_type + ) + if entity_type: + query = query.filter(Movement.entity_type == entity_type) + if entity_types: + query = query.filter(Movement.entity_type.in_(entity_types)) + return query.order_by(Movement.timestamp.desc()).first() + def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]: return db.query(Movement).filter( and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id) @@ -58,4 +89,4 @@ class CRUDMovement: ).order_by(Movement.timestamp).all() -movement = CRUDMovement() \ No newline at end of file +movement = CRUDMovement() diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index 7b14b7e..018252c 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -169,6 +169,36 @@ class CRUDPPR: return db_obj + def acknowledge_strip( + self, + db: Session, + ppr_id: int, + user: str = "system", + user_ip: str = "127.0.0.1" + ) -> Optional[PPRRecord]: + db_obj = self.get(db, ppr_id) + if not db_obj: + return None + + if db_obj.acknowledged_dt: + return db_obj + + db_obj.acknowledged_dt = datetime.utcnow() + db_obj.acknowledged_by = user + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + crud_journal.log_ppr_change( + db, + db_obj.id, + f"Paper strip acknowledged by {user}", + user, + user_ip + ) + + return db_obj + def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]: db_obj = self.get(db, ppr_id) if db_obj: @@ -190,4 +220,4 @@ class CRUDPPR: return db_obj -ppr = CRUDPPR() \ No newline at end of file +ppr = CRUDPPR() diff --git a/backend/app/models/ppr.py b/backend/app/models/ppr.py index 3c2e705..a8ac46c 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -43,6 +43,8 @@ class PPRRecord(Base): departed_dt = Column(DateTime, nullable=True) created_by = Column(String(16), nullable=True, index=True) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) + acknowledged_dt = Column(DateTime, nullable=True) + acknowledged_by = Column(String(50), nullable=True) public_token = Column(String(128), nullable=True, unique=True, index=True) updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) @@ -101,4 +103,4 @@ class UserAircraft(Base): clean_reg = Column(String(25), nullable=False, index=True) created_by = Column(String(16), nullable=False, index=True) created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) - updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) \ No newline at end of file + updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) diff --git a/backend/app/schemas/movement.py b/backend/app/schemas/movement.py index 8104245..962f2c3 100644 --- a/backend/app/schemas/movement.py +++ b/backend/app/schemas/movement.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel -from datetime import datetime +from datetime import date, datetime from app.models.movement import MovementType @@ -26,9 +26,47 @@ class MovementCreate(MovementBase): pass +class BulkMovementLog(BaseModel): + flight_kind: str + movement_date: date + movement_time: Optional[str] = None + takeoff_time: Optional[str] = None + landing_time: Optional[str] = None + contact_time: Optional[str] = None + qsy_time: Optional[str] = None + aircraft_registration: str + aircraft_type: Optional[str] = None + callsign: Optional[str] = None + from_location: Optional[str] = None + to_location: Optional[str] = None + pob: Optional[int] = None + local_nature: Optional[str] = None + circuits: Optional[int] = None + runway: Optional[str] = None + wind: Optional[str] = None + pressure_setting: Optional[str] = None + notes: Optional[str] = None + ppr_id: Optional[int] = None + movement_id: Optional[int] = None + + +class BulkMovementContext(BaseModel): + pprs: List[dict] + movements: List[dict] + suggested: dict + + class Movement(MovementBase): id: int created_at: datetime class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + + +class BulkMovementResult(BaseModel): + action: str + movement: Movement + entity_type: str + entity_id: int + message: str diff --git a/backend/app/schemas/ppr.py b/backend/app/schemas/ppr.py index 8977981..ec2bebf 100644 --- a/backend/app/schemas/ppr.py +++ b/backend/app/schemas/ppr.py @@ -88,6 +88,8 @@ class PPRInDBBase(PPRBase): departed_dt: Optional[datetime] = None created_by: Optional[str] = None submitted_dt: datetime + acknowledged_dt: Optional[datetime] = None + acknowledged_by: Optional[str] = None class Config: from_attributes = True @@ -235,4 +237,4 @@ class UserAircraft(UserAircraftBase): class UserAircraftCreate(BaseModel): registration: str - type_code: str \ No newline at end of file + type_code: str diff --git a/db-init/init_db.sql b/db-init/init_db.sql index dd0d12a..45e8dc1 100644 --- a/db-init/init_db.sql +++ b/db-init/init_db.sql @@ -41,6 +41,8 @@ CREATE TABLE submitted ( departed_dt DATETIME DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + acknowledged_dt DATETIME DEFAULT NULL, + acknowledged_by VARCHAR(50) DEFAULT NULL, public_token VARCHAR(128) DEFAULT NULL UNIQUE, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -130,4 +132,4 @@ WHERE s.status != 'DELETED'; -- Create indexes for the view performance -- ALTER TABLE submitted ADD INDEX idx_in_from (in_from); --- ALTER TABLE submitted ADD INDEX idx_out_to (out_to); \ No newline at end of file +-- ALTER TABLE submitted ADD INDEX idx_out_to (out_to); diff --git a/web/admin.css b/web/admin.css index 705832f..b220b95 100644 --- a/web/admin.css +++ b/web/admin.css @@ -160,6 +160,15 @@ body { background-color: #e67e22; } +.btn-ack { + background-color: #8e44ad; + color: white; +} + +.btn-ack:hover { + background-color: #71368a; +} + .btn-info { background-color: #3498db; color: white; @@ -330,6 +339,21 @@ tbody tr:hover { background-color: #f8f9fa; } +tbody tr.ppr-strip-unacknowledged { + background-color: #fff0c2; + box-shadow: inset 4px 0 0 #f39c12; +} + +tbody tr.ppr-strip-unacknowledged:hover { + background-color: #ffe6a1; +} + +.ack-complete { + color: #1e7e34; + font-size: 0.75rem; + font-weight: 700; +} + .status { display: inline-block; padding: 0.3rem 0.6rem; diff --git a/web/admin.html b/web/admin.html index 1443af7..af5f385 100644 --- a/web/admin.html +++ b/web/admin.html @@ -32,6 +32,7 @@ ๐๏ธ ATC View ๐ Reports ๐ Movements + ๐งพ Bulk Flight Log ๐ Journal Log โ๏ธ User Aircraft @@ -260,6 +261,7 @@