Adding features to find public toilets and shopping streets #41
| @@ -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 | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
							
								
								
									
										103
									
								
								backend/src/tests/test_toilets.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								backend/src/tests/test_toilets.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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. | ||||
|      | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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. | ||||
|          | ||||
|   | ||||
							
								
								
									
										78
									
								
								backend/src/utils/toilets_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								backend/src/utils/toilets_manager.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
		Reference in New Issue
	
	Block a user