From edd8a8b2b943a3f4f08ffc6efac74c8ff0eca72f Mon Sep 17 00:00:00 2001 From: Helldragon67 Date: Sat, 14 Dec 2024 16:52:07 +0100 Subject: [PATCH] new endpoint for toilets --- backend/src/main.py | 45 +++++++--- backend/src/sandbox/get_streets.py | 4 +- backend/src/structs/landmark.py | 25 ++++++ backend/src/tests/test_toilets.py | 103 +++++++++++++++++++++++ backend/src/tests/test_utils.py | 7 +- backend/src/utils/get_time_separation.py | 8 +- backend/src/utils/landmarks_manager.py | 1 + backend/src/utils/optimizer.py | 22 ++--- backend/src/utils/refiner.py | 3 +- backend/src/utils/toilets_manager.py | 78 +++++++++++++++++ 10 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 backend/src/tests/test_toilets.py create mode 100644 backend/src/utils/toilets_manager.py diff --git a/backend/src/main.py b/backend/src/main.py index 0fcc6ce..a2efbf1 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,13 +1,14 @@ """Main app for backend api""" import logging -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query -from .structs.landmark import Landmark +from .structs.landmark import Landmark, Toilets from .structs.preferences import Preferences from .structs.linked_landmarks import LinkedLandmarks from .structs.trip import Trip from .utils.landmarks_manager import LandmarkManager +from .utils.toilets_manager import ToiletsManager from .utils.optimizer import Optimizer from .utils.refiner import Refiner from .persistence import client as cache_client @@ -36,19 +37,15 @@ def new_trip(preferences: Preferences, (uuid) : The uuid of the first landmark in the optimized route """ if preferences is None: - raise HTTPException(status_code=406, - detail="Preferences not provided or incomplete.") + raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.") if (preferences.shopping.score == 0 and preferences.sightseeing.score == 0 and preferences.nature.score == 0) : - raise HTTPException(status_code=406, - detail="All preferences are 0.") + raise HTTPException(status_code=406, detail="All preferences are 0.") if start is None: - raise HTTPException(status_code=406, - detail="Start coordinates not provided") + raise HTTPException(status_code=406, detail="Start coordinates not provided") if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180): - raise HTTPException(status_code=422, - detail="Start coordinates not in range") + raise HTTPException(status_code=422, detail="Start coordinates not in range") if end is None: end = start logger.info("No end coordinates provided. Using start=end.") @@ -135,3 +132,31 @@ def get_landmark(landmark_uuid: str) -> Landmark: return landmark except KeyError as exc: raise HTTPException(status_code=404, detail="Landmark not found") from exc + + +@app.post("/toilets/new") +def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] : + """ + Endpoint to find toilets within a specified radius from a given location. + + This endpoint expects the `location` and `radius` as **query parameters**, not in the request body. + + Args: + location (tuple[float, float]): The latitude and longitude of the location to search from. + radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters. + + Returns: + list[Toilets]: A list of Toilets objects that meet the criteria. + """ + if location is None: + raise HTTPException(status_code=406, detail="Coordinates not provided or invalid") + if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180): + raise HTTPException(status_code=422, detail="Start coordinates not in range") + + toilets_manager = ToiletsManager(location, radius) + + try : + toilets_list = toilets_manager.generate_toilet_list() + return toilets_list + except KeyError as exc: + raise HTTPException(status_code=404, detail="No toilets found") from exc diff --git a/backend/src/sandbox/get_streets.py b/backend/src/sandbox/get_streets.py index c7f604c..7940017 100644 --- a/backend/src/sandbox/get_streets.py +++ b/backend/src/sandbox/get_streets.py @@ -79,8 +79,8 @@ def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int: Calculate the time in minutes to travel from one location to another. Args: - p1 (Tuple[float, float]): Coordinates of the starting location. - p2 (Tuple[float, float]): Coordinates of the destination. + p1 (tuple[float, float]): Coordinates of the starting location. + p2 (tuple[float, float]): Coordinates of the destination. Returns: int: Time to travel from p1 to p2 in minutes. diff --git a/backend/src/structs/landmark.py b/backend/src/structs/landmark.py index 85eb996..a494896 100644 --- a/backend/src/structs/landmark.py +++ b/backend/src/structs/landmark.py @@ -115,3 +115,28 @@ class Landmark(BaseModel) : return (self.uuid == value.uuid or self.osm_id == value.osm_id or (self.name == value.name and self.distance(value) < 0.001)) + + +class Toilets(BaseModel) : + """ + Model for toilets. When false/empty the information is either false either not known. + """ + location : tuple + wheelchair : Optional[bool] = False + changing_table : Optional[bool] = False + fee : Optional[bool] = False + opening_hours : Optional[str] = "" + + + def __str__(self) -> str: + """ + String representation of the Toilets object. + + Returns: + str: A formatted string with the toilets location. + """ + return f'Toilets @{self.location}' + + class Config: + # This allows us to easily convert the model to and from dictionaries + orm_mode = True \ No newline at end of file diff --git a/backend/src/tests/test_toilets.py b/backend/src/tests/test_toilets.py new file mode 100644 index 0000000..89f71a5 --- /dev/null +++ b/backend/src/tests/test_toilets.py @@ -0,0 +1,103 @@ +"""Collection of tests to ensure correct implementation and track progress. """ + +from fastapi.testclient import TestClient +import pytest + +from ..structs.landmark import Toilets +from ..main import app + +@pytest.fixture(scope="module") +def client(): + """Client used to call the app.""" + return TestClient(app) + +@pytest.mark.parametrize( + "location,radius,status_code", + [ + ({}, None, 422), # Invalid case: no location at all. + ([443], None, 422), # Invalid cases: invalid location. + ([443, 433], None, 422), # Invalid cases: invalid location. + ] +) +def test_invalid_input(client, location, radius, status_code): # pylint: disable=redefined-outer-name + """ + Test n°1 : Verify handling of invalid input. + + Args: + client: + request: + """ + response = client.post( + "/toilets/new", + params={ + "location": location, + "radius": radius + } + ) + + # checks : + assert response.status_code == status_code + + + + +@pytest.mark.parametrize( + "location,status_code", + [ + ([48.2270, 7.4370], 200), # Orschwiller. + ([10.2012, 10.123], 200), # Nigerian desert. + ([63.989, -19.677], 200), # Hekla volcano, Iceland + ] +) +def test_no_toilets(client, location, status_code): # pylint: disable=redefined-outer-name + """ + Test n°3 : Verify the code finds some toilets in big cities. + + Args: + client: + request: + """ + response = client.post( + "/toilets/new", + params={ + "location": location + } + ) + toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()] + + # checks : + assert response.status_code == 200 # check for successful planning + assert isinstance(toilets_list, list) # check that the return type is a list + + + +@pytest.mark.parametrize( + "location,status_code", + [ + ([45.7576485, 4.8330241], 200), # Lyon, Bellecour. + ([40.768502, -73.958408], 200), # New York, Upper East Side. + ([53.482864, -2.2411116], 200), # Manchester, centre. + ([-6.913795, 107.60278], 200), # Bandung, train station + ([-22.970140, -43.18181], 200), # Rio de Janeiro, Copacabana + ] +) +def test_toilets(client, location, status_code): # pylint: disable=redefined-outer-name + """ + Test n°3 : Verify the code finds some toilets in big cities. + + Args: + client: + request: + """ + response = client.post( + "/toilets/new", + params={ + "location": location + } + ) + toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()] + + # checks : + assert response.status_code == 200 # check for successful planning + assert isinstance(toilets_list, list) # check that the return type is a list + assert len(toilets_list) > 0 \ No newline at end of file diff --git a/backend/src/tests/test_utils.py b/backend/src/tests/test_utils.py index ed4e271..d5b69ad 100644 --- a/backend/src/tests/test_utils.py +++ b/backend/src/tests/test_utils.py @@ -1,6 +1,5 @@ """Helper methods for testing.""" import logging -from typing import List from fastapi import HTTPException from pydantic import ValidationError @@ -8,7 +7,7 @@ from ..structs.landmark import Landmark from ..persistence import client as cache_client -def landmarks_to_osmid(landmarks: List[Landmark]) -> List[int] : +def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] : """ Convert the list of landmarks into a list containing their osm ids for quick landmark checking. @@ -95,7 +94,7 @@ def fetch_landmark_cache(landmark_uuid: str): -def load_trip_landmarks(client, first_uuid: str, from_cache=None) -> List[Landmark]: +def load_trip_landmarks(client, first_uuid: str, from_cache=None) -> list[Landmark]: """ Load all landmarks for a trip using the response from the API. @@ -120,7 +119,7 @@ def load_trip_landmarks(client, first_uuid: str, from_cache=None) -> List[Landma return landmarks -def log_trip_details(request, landmarks: List[Landmark], duration: int, target_duration: int) : +def log_trip_details(request, landmarks: list[Landmark], duration: int, target_duration: int) : """ Allows to show the detailed trip in the html test report. diff --git a/backend/src/utils/get_time_separation.py b/backend/src/utils/get_time_separation.py index 210c28d..c8bd509 100644 --- a/backend/src/utils/get_time_separation.py +++ b/backend/src/utils/get_time_separation.py @@ -15,8 +15,8 @@ def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int: Calculate the time in minutes to travel from one location to another. Args: - p1 (Tuple[float, float]): Coordinates of the starting location. - p2 (Tuple[float, float]): Coordinates of the destination. + p1 (tuple[float, float]): Coordinates of the starting location. + p2 (tuple[float, float]): Coordinates of the destination. Returns: int: Time to travel from p1 to p2 in minutes. @@ -55,8 +55,8 @@ def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int: Calculate the time in minutes to travel from one location to another. Args: - p1 (Tuple[float, float]): Coordinates of the starting location. - p2 (Tuple[float, float]): Coordinates of the destination. + p1 (tuple[float, float]): Coordinates of the starting location. + p2 (tuple[float, float]): Coordinates of the destination. Returns: int: Time to travel from p1 to p2 in minutes. diff --git a/backend/src/utils/landmarks_manager.py b/backend/src/utils/landmarks_manager.py index 883fca9..c5e6091 100644 --- a/backend/src/utils/landmarks_manager.py +++ b/backend/src/utils/landmarks_manager.py @@ -79,6 +79,7 @@ class LandmarkManager: # Create a bbox using the around technique bbox = tuple((f"around:{reachable_bbox_side/2}", str(center_coordinates[0]), str(center_coordinates[1]))) + # list for sightseeing if preferences.sightseeing.score != 0: score_function = lambda score: score * 10 * preferences.sightseeing.score / 5 diff --git a/backend/src/utils/optimizer.py b/backend/src/utils/optimizer.py index e20aa65..d7f354e 100644 --- a/backend/src/utils/optimizer.py +++ b/backend/src/utils/optimizer.py @@ -44,7 +44,7 @@ class Optimizer: resx (list[float]): List of edge weights. Returns: - Tuple[list[int], list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector. + tuple[list[int], list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector. """ for i, elem in enumerate(resx): @@ -79,7 +79,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[np.ndarray, list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector. + tuple[np.ndarray, list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector. """ l1 = [0]*L*L @@ -107,7 +107,7 @@ class Optimizer: resx (list): List of edge weights. Returns: - Tuple[list[int], Optional[list[list[int]]]]: A tuple containing the visit order and a list of any detected circles. + tuple[list[int], Optional[list[list[int]]]]: A tuple containing the visit order and a list of any detected circles. """ # first round the results to have only 0-1 values @@ -180,7 +180,7 @@ class Optimizer: max_time (int): Maximum time of visit allowed. Returns: - Tuple[list[float], list[float], list[int]]: Objective function coefficients, inequality constraint coefficients, and the right-hand side of the inequality constraint. + tuple[list[float], list[float], list[int]]: Objective function coefficients, inequality constraint coefficients, and the right-hand side of the inequality constraint. """ # Objective function coefficients. a*x1 + b*x2 + c*x3 + ... @@ -212,7 +212,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ ones = [1]*L @@ -239,7 +239,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ upper_ind = np.triu_indices(L,0,L) @@ -270,7 +270,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[list[np.ndarray], list[int]]: Equality constraint coefficients and the right-hand side of the equality constraints. + tuple[list[np.ndarray], list[int]]: Equality constraint coefficients and the right-hand side of the equality constraints. """ l = [0]*L*L @@ -293,7 +293,7 @@ class Optimizer: landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ L = len(landmarks) @@ -319,7 +319,7 @@ class Optimizer: landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_avoid'. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ L = len(landmarks) @@ -346,7 +346,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ l_start = [1]*L + [0]*L*(L-1) # sets departures only for start (horizontal ones) @@ -374,7 +374,7 @@ class Optimizer: L (int): Number of landmarks. Returns: - Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. + tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. """ A = [0]*L*L diff --git a/backend/src/utils/refiner.py b/backend/src/utils/refiner.py index 28227a6..1f9c755 100644 --- a/backend/src/utils/refiner.py +++ b/backend/src/utils/refiner.py @@ -2,7 +2,6 @@ import yaml, logging from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull from math import pi -from typing import List from ..structs.landmark import Landmark from . import take_most_important, get_time_separation @@ -135,7 +134,7 @@ class Refiner : return tour - def integrate_landmarks(self, sub_list: List[Landmark], main_list: List[Landmark]) : + def integrate_landmarks(self, sub_list: list[Landmark], main_list: list[Landmark]) : """ Inserts 'sub_list' of Landmarks inside the 'main_list' by leaving the ends untouched. diff --git a/backend/src/utils/toilets_manager.py b/backend/src/utils/toilets_manager.py new file mode 100644 index 0000000..210d00b --- /dev/null +++ b/backend/src/utils/toilets_manager.py @@ -0,0 +1,78 @@ +import logging, yaml +from OSMPythonTools.overpass import Overpass, overpassQueryBuilder +from OSMPythonTools.cachingStrategy import CachingStrategy, JSON + +from ..structs.landmark import Toilets +from ..constants import LANDMARK_PARAMETERS_PATH, OSM_CACHE_DIR + + +# silence the overpass logger +logging.getLogger('OSMPythonTools').setLevel(level=logging.CRITICAL) + +class ToiletsManager: + + logger = logging.getLogger(__name__) + + location: tuple[float, float] + radius: int # radius in meters + + + def __init__(self, location: tuple[float, float], radius : int) -> None: + + self.radius = radius + self.location = location + self.overpass = Overpass() + CachingStrategy.use(JSON, cacheDir=OSM_CACHE_DIR) + + + def generate_toilet_list(self) -> list[Toilets] : + + + # Create a bbox using the around technique + bbox = tuple((f"around:{self.radius}", str(self.location[0]), str(self.location[1]))) + toilets_list = [] + + query = overpassQueryBuilder( + bbox = bbox, + elementType = ['node', 'way', 'relation'], + # selector can in principle be a list already, + # but it generates the intersection of the queries + # we want the union + selector = ['"amenity"="toilets"'], + includeCenter = True, + out = 'center' + ) + self.logger.debug(f"Query: {query}") + + try: + result = self.overpass.query(query) + except Exception as e: + self.logger.error(f"Error fetching landmarks: {e}") + return None + + for elem in result.elements(): + location = (elem.centerLat(), elem.centerLon()) + + # handle unprecise and no-name locations + if location[0] is None: + location = (elem.lat(), elem.lon()) + else : + continue + + toilets = Toilets(location=location) + + if 'wheelchair' in elem.tags().keys() and elem.tag('wheelchair') == 'yes': + toilets.wheelchair = True + + if 'changing_table' in elem.tags().keys() and elem.tag('changing_table') == 'yes': + toilets.changing_table = True + + if 'fee' in elem.tags().keys() and elem.tag('fee') == 'yes': + toilets.fee = True + + if 'opening_hours' in elem.tags().keys() : + toilets.opening_hours = elem.tag('opening_hours') + + toilets_list.append(toilets) + + return toilets_list