diff --git a/idunn/api/directions.py b/idunn/api/directions.py index e65d11723..f5e788cb4 100644 --- a/idunn/api/directions.py +++ b/idunn/api/directions.py @@ -1,5 +1,7 @@ +from datetime import datetime from fastapi import HTTPException, Query, Path, Request, Response, Depends from pydantic import confloat +from typing import Optional from idunn import settings from idunn.places import Latlon @@ -19,23 +21,34 @@ def directions_request(request: Request, response: Response): async def get_directions_with_coordinates( # URL values - f_lon: confloat(ge=-180, le=180) = Path(..., title="Origin point longitude"), - f_lat: confloat(ge=-90, le=90) = Path(..., title="Origin point latitude"), - t_lon: confloat(ge=-180, le=180) = Path(..., title="Destination point longitude"), - t_lat: confloat(ge=-90, le=90) = Path(..., title="Destination point latitude"), + f_lon: confloat(ge=-180, le=180) = Path(title="Origin point longitude"), + f_lat: confloat(ge=-90, le=90) = Path(title="Origin point latitude"), + t_lon: confloat(ge=-180, le=180) = Path(title="Destination point longitude"), + t_lat: confloat(ge=-90, le=90) = Path(title="Destination point latitude"), # Query parameters type: str = Query(..., description="Transport mode"), language: str = "en", + # Time parameters + arrive_by: Optional[datetime] = Query(None, title="Local arrival time"), + depart_at: Optional[datetime] = Query(None, title="Local departure time"), # Request request: Request = Depends(directions_request), ): """Get directions to get from a point to another.""" from_place = Latlon(f_lat, f_lon) to_place = Latlon(t_lat, t_lon) + + if arrive_by and depart_at: + raise HTTPException( + status_code=400, + detail="`arrive_by` and `depart_at` can't both be specified", + ) + if not type: raise HTTPException(status_code=400, detail='"type" query param is required') + return await directions_client.get_directions( - from_place, to_place, type, language, extra=request.query_params + from_place, to_place, type, language, arrive_by, depart_at, extra=request.query_params ) @@ -45,6 +58,9 @@ async def get_directions( destination: str = Query(..., description="Destination place id."), type: str = Query(..., description="Transport mode."), language: str = Query("en", description="User language."), + # Time parameters + arrive_by: Optional[datetime] = Query(None, title="Local arrival time"), + depart_at: Optional[datetime] = Query(None, title="Local departure time"), # Request request: Request = Depends(directions_request), ): @@ -55,6 +71,12 @@ async def get_directions( except IdunnPlaceError as exc: raise HTTPException(status_code=404, detail=exc.message) from exc + if arrive_by and depart_at: + raise HTTPException( + status_code=400, + detail="`arrive_by` and `depart_at` can't both be specified", + ) + return await directions_client.get_directions( - from_place, to_place, type, language, extra=request.query_params + from_place, to_place, type, language, arrive_by, depart_at, extra=request.query_params ) diff --git a/idunn/datasources/directions/__init__.py b/idunn/datasources/directions/__init__.py index f40bc76d4..9b6f75167 100644 --- a/idunn/datasources/directions/__init__.py +++ b/idunn/datasources/directions/__init__.py @@ -1,5 +1,6 @@ import logging from abc import ABC, abstractmethod, abstractproperty +from datetime import datetime from fastapi import HTTPException from pydantic import BaseModel from typing import Callable, Optional @@ -58,6 +59,8 @@ async def get_directions( to_place: BasePlace, mode: IdunnTransportMode, lang: str, + arrive_by: Optional[datetime], + depart_at: Optional[datetime], extra: Optional[QueryParams] = None, ) -> DirectionsResponse: idunn_mode = IdunnTransportMode.parse(mode) @@ -80,7 +83,9 @@ async def get_directions( ) # pylint: disable = not-callable - return await method.get_directions(from_place, to_place, idunn_mode, lang, extra) + return await method.get_directions( + from_place, to_place, idunn_mode, lang, arrive_by, depart_at, extra + ) directions_client = DirectionsClient() diff --git a/idunn/datasources/directions/abs_client.py b/idunn/datasources/directions/abs_client.py index dece555cf..f64216729 100644 --- a/idunn/datasources/directions/abs_client.py +++ b/idunn/datasources/directions/abs_client.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from datetime import datetime from typing import Optional from idunn.geocoder.models.params import QueryParams @@ -19,6 +20,8 @@ async def get_directions( to_place: BasePlace, mode: IdunnTransportMode, lang: str, + arrive_by: Optional[datetime], + depart_at: Optional[datetime], extra: Optional[QueryParams] = None, ) -> DirectionsResponse: ... diff --git a/idunn/datasources/directions/hove/client.py b/idunn/datasources/directions/hove/client.py index 9152ab0ab..a70649c61 100644 --- a/idunn/datasources/directions/hove/client.py +++ b/idunn/datasources/directions/hove/client.py @@ -1,4 +1,5 @@ import httpx +from datetime import datetime from fastapi import HTTPException from typing import Optional @@ -36,6 +37,8 @@ async def get_directions( to_place: BasePlace, mode: IdunnTransportMode, _lang: str, + arrive_by: Optional[datetime], + depart_at: Optional[datetime], _extra: Optional[QueryParams] = None, ) -> HoveResponse: if not self.API_ENABLED: @@ -46,6 +49,7 @@ async def get_directions( from_place = from_place.get_coord() to_place = to_place.get_coord() + date_time = arrive_by or depart_at params = { "from": f"{from_place['lon']};{from_place['lat']}", @@ -65,6 +69,14 @@ async def get_directions( "direct_path": "only", } ), + **( + { + "datetime": date_time.isoformat(), + "datetime_represents": "arrival" if arrive_by else "departure", + } + if date_time + else {} + ), } response = await self.session.get( diff --git a/idunn/datasources/directions/mapbox/client.py b/idunn/datasources/directions/mapbox/client.py index d42d76f11..1351aaca1 100644 --- a/idunn/datasources/directions/mapbox/client.py +++ b/idunn/datasources/directions/mapbox/client.py @@ -1,5 +1,6 @@ import httpx import logging +from datetime import datetime from fastapi import HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -48,6 +49,8 @@ async def get_directions( to_place: BasePlace, mode: IdunnTransportMode, lang: str, + arrive_by: Optional[datetime], + depart_at: Optional[datetime], extra: Optional[QueryParams] = None, ) -> DirectionsResponse: if not self.API_ENABLED: @@ -70,6 +73,8 @@ async def get_directions( params={ "language": lang, "access_token": settings["MAPBOX_DIRECTIONS_ACCESS_TOKEN"], + **({"arrive_by": arrive_by.isoformat()} if arrive_by else {}), + **({"depart_at": depart_at.isoformat()} if depart_at else {}), **MapboxAPIExtraParams(**extra).dict(exclude_none=True), }, timeout=self.request_timeout, diff --git a/tests/test_directions.py b/tests/test_directions.py index cbd2afb7b..da139ac19 100644 --- a/tests/test_directions.py +++ b/tests/test_directions.py @@ -54,7 +54,48 @@ def test_direction_car(mock_directions_car): assert len(response_data["data"]["routes"][0]["legs"]) == 1 assert len(response_data["data"]["routes"][0]["legs"][0]["steps"]) == 10 assert response_data["data"]["routes"][0]["legs"][0]["mode"] == "CAR" - assert "exclude=ferry" in str(mock_directions_car.calls[0].request.url) + mocked_request_url = str(mock_directions_car.calls[0].request.url) + assert "exclude=ferry" in mocked_request_url + + # Check that we leave these at defaults + assert "arrive_by" not in mocked_request_url + assert "depart_at" not in mocked_request_url + + +@freeze_time("2018-06-14 8:30:00", tz_offset=0) +def test_direction_car_arrive_by(mock_directions_car): + client = TestClient(app) + client.get( + "http://localhost/v1/directions/2.3402355%2C48.8900732%3B2.3688579%2C48.8529869", + params={ + "language": "fr", + "type": "driving", + "exclude": "ferry", + "arrive_by": "2018-06-14T10:30:00", + }, + ) + + mocked_request_url = str(mock_directions_car.calls[0].request.url) + assert "arrive_by" in mocked_request_url + assert "depart_at" not in mocked_request_url + + +@freeze_time("2018-06-14 8:30:00", tz_offset=0) +def test_direction_car_depart_at(mock_directions_car): + client = TestClient(app) + client.get( + "http://localhost/v1/directions/2.3402355%2C48.8900732%3B2.3688579%2C48.8529869", + params={ + "language": "fr", + "type": "driving", + "exclude": "ferry", + "depart_at": "2018-06-14T10:30:00", + }, + ) + + mocked_request_url = str(mock_directions_car.calls[0].request.url) + assert "arrive_by" not in mocked_request_url + assert "depart_at" in mocked_request_url def test_direction_car_with_ids(mock_directions_car): diff --git a/tests/test_directions_hove.py b/tests/test_directions_hove.py index c1622d59b..77ed2cc70 100644 --- a/tests/test_directions_hove.py +++ b/tests/test_directions_hove.py @@ -64,6 +64,7 @@ def test_directions_pt(mock_directions_pt): assert "from=2.34024;48.89007" in mocked_url assert "to=2.36886;48.85299" in mocked_url assert "direct_path=none" in mocked_url + assert "datetime" not in mocked_url def test_directions_hove_not_configured(): @@ -74,3 +75,33 @@ def test_directions_hove_not_configured(): params={"type": "publictransport"}, ) assert response.status_code == 501 + + +def test_direction_hove_arrive_by(mock_directions_pt): + client = TestClient(app) + client.get( + "http://localhost/v1/directions/2.3402355%2C48.8900732%3B2.3688579%2C48.8529869", + params={ + "type": "publictransport", + "arrive_by": "2018-06-14T10:30:00", + }, + ) + + mocked_request_url = str(mock_directions_pt.calls[0].request.url) + assert "datetime" in mocked_request_url + assert "datetime_represents=arrival" in mocked_request_url + + +def test_direction_hove_depart_at(mock_directions_pt): + client = TestClient(app) + client.get( + "http://localhost/v1/directions/2.3402355%2C48.8900732%3B2.3688579%2C48.8529869", + params={ + "type": "publictransport", + "depart_at": "2018-06-14T10:30:00", + }, + ) + + mocked_request_url = str(mock_directions_pt.calls[0].request.url) + assert "datetime" in mocked_request_url + assert "datetime_represents=departure" in mocked_request_url