PPR ACK and Bulk Logging start
This commit is contained in:
@@ -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')
|
||||
@@ -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
|
||||
return movement
|
||||
|
||||
@@ -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 "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
movement = CRUDMovement()
|
||||
|
||||
@@ -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()
|
||||
ppr = CRUDPPR()
|
||||
|
||||
@@ -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())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
|
||||
@@ -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
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BulkMovementResult(BaseModel):
|
||||
action: str
|
||||
movement: Movement
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
message: str
|
||||
|
||||
@@ -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
|
||||
type_code: str
|
||||
|
||||
Reference in New Issue
Block a user