diff --git a/simulator/admin.py b/simulator/admin.py index a0a88d1..7edc52b 100644 --- a/simulator/admin.py +++ b/simulator/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin +from django.contrib import messages from .models import Carrier, AircraftBase, Equipment, Aerodrome, Flight +from .services import cancel_flight_cascade # Register your models here. admin.site.register(Carrier) @@ -19,6 +21,7 @@ class FlightAdmin(admin.ModelAdmin): # type: ignore[type-arg] "arrival_time_display", "status_display", ) + actions = ["cancel_selected_flights"] @admin.display(description="Flight") def flight_number_display(self, obj): @@ -31,3 +34,24 @@ class FlightAdmin(admin.ModelAdmin): # type: ignore[type-arg] @admin.display(description="Status") def status_display(self, obj): return obj.status + + @admin.action(description="Cancel selected flights (and dependent flights)") + def cancel_selected_flights(self, request, queryset): + total_canceled = [] + for flight in queryset: + if not flight.canceled: + canceled_flights = cancel_flight_cascade(flight) + total_canceled.extend(canceled_flights) + + if total_canceled: + self.message_user( + request, + f"Canceled {len(total_canceled)} flight(s) including dependent flights.", + messages.SUCCESS, + ) + else: + self.message_user( + request, + "No flights were canceled (already canceled).", + messages.WARNING, + ) diff --git a/simulator/migrations/0005_flight_canceled.py b/simulator/migrations/0005_flight_canceled.py new file mode 100644 index 0000000..6261517 --- /dev/null +++ b/simulator/migrations/0005_flight_canceled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-01 04:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('simulator', '0004_route_aerodrome_altitude'), + ] + + operations = [ + migrations.AddField( + model_name='flight', + name='canceled', + field=models.BooleanField(default=False), + ), + ] diff --git a/simulator/models/flight.py b/simulator/models/flight.py index 34192ca..980aa0b 100644 --- a/simulator/models/flight.py +++ b/simulator/models/flight.py @@ -28,6 +28,7 @@ class Flight(models.Model): validators=[MinValueValidator(1), MaxValueValidator(9999)] ) departure_time = models.DateTimeField() + canceled = models.BooleanField(default=False) class Meta: constraints = [ @@ -59,11 +60,9 @@ class Flight(models.Model): return self.departure_time + self.duration def clean(self): - # Validate origin and destination are different if self.origin == self.destination: raise ValidationError("Origin and destination airports cannot be the same.") - # Validate carrier owns the equipment if self.equipment.owner != self.carrier: raise ValidationError( f"{self.equipment} is owned by {self.equipment.owner}, " @@ -76,14 +75,18 @@ class Flight(models.Model): f"maximum range ({self.equipment.model.range_nm} nm)." ) - previous_flight = ( - Flight.objects.filter( - equipment=self.equipment, departure_time__lt=self.departure_time - ) - .order_by("-departure_time") - .exclude(pk=self.pk) - .first() - ) + previous_flights = Flight.objects.filter( + equipment=self.equipment, + departure_time__lt=self.departure_time, + canceled=False, + ).exclude(pk=self.pk) + + previous_flight = None + latest_arrival = None + for flight in previous_flights: + if latest_arrival is None or flight.arrival_time > latest_arrival: + latest_arrival = flight.arrival_time + previous_flight = flight if previous_flight: if previous_flight.destination != self.origin: @@ -112,6 +115,8 @@ class Flight(models.Model): @property def status(self): + if self.canceled: + return "Canceled" now = timezone.now() if now < self.departure_time: return "Scheduled" diff --git a/simulator/services/__init__.py b/simulator/services/__init__.py new file mode 100644 index 0000000..3649fe9 --- /dev/null +++ b/simulator/services/__init__.py @@ -0,0 +1,3 @@ +from .flight_service import cancel_flight_cascade + +__all__ = ["cancel_flight_cascade"] diff --git a/simulator/services/flight_service.py b/simulator/services/flight_service.py new file mode 100644 index 0000000..a73be85 --- /dev/null +++ b/simulator/services/flight_service.py @@ -0,0 +1,68 @@ +""" +Flight service module for handling complex flight operations. +""" + +from typing import List +from simulator.models.flight import Flight + + +def cancel_flight_cascade(flight: Flight) -> List[Flight]: + """ + Cancel a flight and all subsequent flights that depend on it. + + Returns a list of all canceled flights (including the original). + """ + canceled_flights = [] + + def cancel_recursive(current_flight: Flight): + """Recursively cancel flights that depend on the current flight.""" + if current_flight.canceled: + return # Already canceled + + # Mark flight as canceled + current_flight.canceled = True + current_flight.save(update_fields=["canceled"]) + canceled_flights.append(current_flight) + + # Find all flights that depart from this flight's destination + # after this flight's arrival time + dependent_flights = Flight.objects.filter( + equipment=current_flight.equipment, + origin=current_flight.destination, + departure_time__gte=current_flight.arrival_time, + canceled=False, + ).order_by("departure_time") + + # Check if any of these flights actually depend on this flight + for next_flight in dependent_flights: + # Find what the previous non-canceled flight would be if we cancel current_flight + previous_non_canceled = ( + Flight.objects.filter( + equipment=current_flight.equipment, + departure_time__lt=next_flight.departure_time, + canceled=False, + ) + .exclude(pk=current_flight.pk) + .exclude(pk=next_flight.pk) + ) + + # Find the one that arrives last + latest_arrival = None + previous_flight = None + for pf in previous_non_canceled: + if latest_arrival is None or pf.arrival_time > latest_arrival: + latest_arrival = pf.arrival_time + previous_flight = pf + + # Determine where the equipment would be + if previous_flight: + expected_location = previous_flight.destination + else: + expected_location = current_flight.equipment.base_location + + # If the next flight can't depart from its origin, cancel it + if expected_location != next_flight.origin: + cancel_recursive(next_flight) + + cancel_recursive(flight) + return canceled_flights