diff --git a/src/controllers/flight.py b/src/controllers/flight.py index 754ea5d..f3cf7f2 100644 --- a/src/controllers/flight.py +++ b/src/controllers/flight.py @@ -145,6 +145,28 @@ async def get_rocketpy_flight_rpy( flight_service = FlightService.from_flight_model(flight.flight) return flight_service.get_flight_rpy() + @controller_exception_handler + async def get_flight_kml( + self, + flight_id: str, + ) -> bytes: + """ + Get the flight trajectory as a KML file. + + Args: + flight_id: str + + Returns: + bytes (KML XML) + + Raises: + HTTP 404 Not Found: If the flight is not found + in the database. + """ + flight = await self.get_flight_by_id(flight_id) + flight_service = FlightService.from_flight_model(flight.flight) + return flight_service.get_flight_kml() + @controller_exception_handler async def get_flight_simulation( self, diff --git a/src/routes/flight.py b/src/routes/flight.py index 55e82c9..b9b1a46 100644 --- a/src/routes/flight.py +++ b/src/routes/flight.py @@ -301,6 +301,42 @@ async def update_flight_rocket( ) +@router.get( + "/{flight_id}/kml", + responses={ + 200: { + "description": "KML trajectory file download", + "content": {"application/vnd.google-earth.kml+xml": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_flight_kml( + flight_id: str, + controller: FlightControllerDep, +): + """ + Export a flight trajectory as a KML file for Google Earth. + + ## Args + ``` flight_id: str ``` + """ + with tracer.start_as_current_span("get_flight_kml"): + kml = await controller.get_flight_kml(flight_id) + headers = { + "Content-Disposition": ( + f'attachment; filename="flight_{flight_id}.kml"' + ), + } + return Response( + content=kml, + headers=headers, + media_type="application/vnd.google-earth.kml+xml", + status_code=200, + ) + + @router.get("/{flight_id}/simulate") async def get_flight_simulation( flight_id: str, diff --git a/src/services/flight.py b/src/services/flight.py index b1eb21b..de01658 100644 --- a/src/services/flight.py +++ b/src/services/flight.py @@ -1,9 +1,12 @@ import json +import os +import tempfile from typing import Self, Tuple import numpy as np from rocketpy.simulation.flight import Flight as RocketPyFlight +from rocketpy.simulation.flight_data_exporter import FlightDataExporter from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder from rocketpy.mathutils.function import Function from rocketpy.motors.solid_motor import SolidMotor @@ -499,6 +502,25 @@ def get_flight_simulation(self) -> FlightSimulation: flight_simulation = FlightSimulation(**encoded_attributes) return flight_simulation + def get_flight_kml(self) -> bytes: + """ + Get the flight trajectory as a KML file for Google Earth. + + Returns: + bytes (UTF-8 encoded KML) + """ + with tempfile.NamedTemporaryFile( + suffix=".kml", delete=False + ) as tmp: + tmp_path = tmp.name + try: + FlightDataExporter(self._flight).export_kml(file_name=tmp_path) + with open(tmp_path, "rb") as fh: + return fh.read() + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + def get_flight_rpy(self) -> bytes: """ Get the portable JSON ``.rpy`` representation of the flight. diff --git a/tests/unit/test_routes/test_flights_route.py b/tests/unit/test_routes/test_flights_route.py index 2349c8d..dd41cae 100644 --- a/tests/unit/test_routes/test_flights_route.py +++ b/tests/unit/test_routes/test_flights_route.py @@ -57,6 +57,7 @@ def mock_controller_instance(): mock_controller.get_rocketpy_flight_rpy = AsyncMock() mock_controller.import_flight_from_rpy = AsyncMock() mock_controller.get_flight_notebook = AsyncMock() + mock_controller.get_flight_kml = AsyncMock() mock_controller.update_environment_by_flight_id = AsyncMock() mock_controller.update_rocket_by_flight_id = AsyncMock() mock_controller.create_flight_from_references = AsyncMock() @@ -539,6 +540,38 @@ def test_read_rocketpy_flight_rpy_server_error(mock_controller_instance): assert response.json() == {'detail': 'Internal Server Error'} +def test_read_flight_kml(mock_controller_instance): + kml_bytes = b'' + mock_controller_instance.get_flight_kml = AsyncMock(return_value=kml_bytes) + response = client.get('/flights/123/kml') + assert response.status_code == 200 + assert response.content == kml_bytes + assert ( + response.headers['content-type'] + == 'application/vnd.google-earth.kml+xml' + ) + assert 'flight_123.kml' in response.headers['content-disposition'] + mock_controller_instance.get_flight_kml.assert_called_once_with('123') + + +def test_read_flight_kml_not_found(mock_controller_instance): + mock_controller_instance.get_flight_kml.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/flights/123/kml') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_read_flight_kml_server_error(mock_controller_instance): + mock_controller_instance.get_flight_kml.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/flights/123/kml') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + # --- Issue #56: Import flight from .rpy ---