new endpoint for toilets

This commit is contained in:
Helldragon67 2024-12-14 16:52:07 +01:00
parent 2033941953
commit edd8a8b2b9
10 changed files with 263 additions and 33 deletions

@ -1,13 +1,14 @@
"""Main app for backend api""" """Main app for backend api"""
import logging 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.preferences import Preferences
from .structs.linked_landmarks import LinkedLandmarks from .structs.linked_landmarks import LinkedLandmarks
from .structs.trip import Trip from .structs.trip import Trip
from .utils.landmarks_manager import LandmarkManager from .utils.landmarks_manager import LandmarkManager
from .utils.toilets_manager import ToiletsManager
from .utils.optimizer import Optimizer from .utils.optimizer import Optimizer
from .utils.refiner import Refiner from .utils.refiner import Refiner
from .persistence import client as cache_client 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 (uuid) : The uuid of the first landmark in the optimized route
""" """
if preferences is None: if preferences is None:
raise HTTPException(status_code=406, raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
detail="Preferences not provided or incomplete.")
if (preferences.shopping.score == 0 and if (preferences.shopping.score == 0 and
preferences.sightseeing.score == 0 and preferences.sightseeing.score == 0 and
preferences.nature.score == 0) : preferences.nature.score == 0) :
raise HTTPException(status_code=406, raise HTTPException(status_code=406, detail="All preferences are 0.")
detail="All preferences are 0.")
if start is None: if start is None:
raise HTTPException(status_code=406, raise HTTPException(status_code=406, detail="Start coordinates not provided")
detail="Start coordinates not provided")
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180): if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
raise HTTPException(status_code=422, raise HTTPException(status_code=422, detail="Start coordinates not in range")
detail="Start coordinates not in range")
if end is None: if end is None:
end = start end = start
logger.info("No end coordinates provided. Using start=end.") logger.info("No end coordinates provided. Using start=end.")
@ -135,3 +132,31 @@ def get_landmark(landmark_uuid: str) -> Landmark:
return landmark return landmark
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail="Landmark not found") from 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. Calculate the time in minutes to travel from one location to another.
Args: Args:
p1 (Tuple[float, float]): Coordinates of the starting location. p1 (tuple[float, float]): Coordinates of the starting location.
p2 (Tuple[float, float]): Coordinates of the destination. p2 (tuple[float, float]): Coordinates of the destination.
Returns: Returns:
int: Time to travel from p1 to p2 in minutes. int: Time to travel from p1 to p2 in minutes.

@ -115,3 +115,28 @@ class Landmark(BaseModel) :
return (self.uuid == value.uuid or return (self.uuid == value.uuid or
self.osm_id == value.osm_id or self.osm_id == value.osm_id or
(self.name == value.name and self.distance(value) < 0.001)) (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

@ -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.""" """Helper methods for testing."""
import logging import logging
from typing import List
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
@ -8,7 +7,7 @@ from ..structs.landmark import Landmark
from ..persistence import client as cache_client 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. 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. 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 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. 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. Calculate the time in minutes to travel from one location to another.
Args: Args:
p1 (Tuple[float, float]): Coordinates of the starting location. p1 (tuple[float, float]): Coordinates of the starting location.
p2 (Tuple[float, float]): Coordinates of the destination. p2 (tuple[float, float]): Coordinates of the destination.
Returns: Returns:
int: Time to travel from p1 to p2 in minutes. 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. Calculate the time in minutes to travel from one location to another.
Args: Args:
p1 (Tuple[float, float]): Coordinates of the starting location. p1 (tuple[float, float]): Coordinates of the starting location.
p2 (Tuple[float, float]): Coordinates of the destination. p2 (tuple[float, float]): Coordinates of the destination.
Returns: Returns:
int: Time to travel from p1 to p2 in minutes. int: Time to travel from p1 to p2 in minutes.

@ -79,6 +79,7 @@ class LandmarkManager:
# Create a bbox using the around technique # Create a bbox using the around technique
bbox = tuple((f"around:{reachable_bbox_side/2}", str(center_coordinates[0]), str(center_coordinates[1]))) bbox = tuple((f"around:{reachable_bbox_side/2}", str(center_coordinates[0]), str(center_coordinates[1])))
# list for sightseeing # list for sightseeing
if preferences.sightseeing.score != 0: if preferences.sightseeing.score != 0:
score_function = lambda score: score * 10 * preferences.sightseeing.score / 5 score_function = lambda score: score * 10 * preferences.sightseeing.score / 5

@ -44,7 +44,7 @@ class Optimizer:
resx (list[float]): List of edge weights. resx (list[float]): List of edge weights.
Returns: 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): for i, elem in enumerate(resx):
@ -79,7 +79,7 @@ class Optimizer:
L (int): Number of landmarks. L (int): Number of landmarks.
Returns: 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 l1 = [0]*L*L
@ -107,7 +107,7 @@ class Optimizer:
resx (list): List of edge weights. resx (list): List of edge weights.
Returns: 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 # first round the results to have only 0-1 values
@ -180,7 +180,7 @@ class Optimizer:
max_time (int): Maximum time of visit allowed. max_time (int): Maximum time of visit allowed.
Returns: 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 + ... # Objective function coefficients. a*x1 + b*x2 + c*x3 + ...
@ -212,7 +212,7 @@ class Optimizer:
L (int): Number of landmarks. L (int): Number of landmarks.
Returns: 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 ones = [1]*L
@ -239,7 +239,7 @@ class Optimizer:
L (int): Number of landmarks. L (int): Number of landmarks.
Returns: 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) upper_ind = np.triu_indices(L,0,L)
@ -270,7 +270,7 @@ class Optimizer:
L (int): Number of landmarks. L (int): Number of landmarks.
Returns: 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 l = [0]*L*L
@ -293,7 +293,7 @@ class Optimizer:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'. landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'.
Returns: 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) L = len(landmarks)
@ -319,7 +319,7 @@ class Optimizer:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_avoid'. landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_avoid'.
Returns: 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) L = len(landmarks)
@ -346,7 +346,7 @@ class Optimizer:
L (int): Number of landmarks. L (int): Number of landmarks.
Returns: 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) 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. L (int): Number of landmarks.
Returns: 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 A = [0]*L*L

@ -2,7 +2,6 @@ import yaml, logging
from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
from math import pi from math import pi
from typing import List
from ..structs.landmark import Landmark from ..structs.landmark import Landmark
from . import take_most_important, get_time_separation from . import take_most_important, get_time_separation
@ -135,7 +134,7 @@ class Refiner :
return tour 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. Inserts 'sub_list' of Landmarks inside the 'main_list' by leaving the ends untouched.

@ -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