team-3/src/c3nav/routing/api/routing.py
2024-12-16 16:13:21 +01:00

360 lines
13 KiB
Python

from enum import StrEnum
from typing import Annotated, Any, Optional, Union
from django.core.exceptions import ValidationError
from django.conf import settings
from django.db.models import Model
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from ninja import Field as APIField
from ninja import Router as APIRouter
from ninja import Schema
from pydantic import PositiveInt
from c3nav.api.auth import APIKeyAuth, auth_responses, validate_responses
from c3nav.api.exceptions import APIRequestValidationFailed
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.api.base import api_stats_clean_location_value
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.models.locations import Position
from c3nav.mapdata.schemas.model_base import AnyLocationID, Coordinates3D, TitledSchema, DjangoModelSchema
from c3nav.mapdata.schemas.models import SlimLocationSchema, SpaceSchema, LevelSchema, SlimSpaceLocationSchema, \
SlimLevelLocationSchema
from c3nav.mapdata.utils.cache.stats import increment_cache_key
from c3nav.mapdata.utils.locations import visible_locations_for_request
from c3nav.routing.exceptions import LocationUnreachable, NoRouteFound, NotYetRoutable
from c3nav.routing.forms import RouteForm
from c3nav.routing.models import RouteOptions
from c3nav.routing.router import Router
routing_api_router = APIRouter(tags=["routing"])
class RouteMode(StrEnum):
""" how to optimize the route """
FASTEST = "fastest"
SHORTEST = "shortest"
class WalkSpeed(StrEnum):
""" the walk speed """
SLOW = "slow"
DEFAULT = "default"
FAST = "fast"
class RestrictedAreas(StrEnum):
""" whether to prefer or avoid restricted areas """
NORMAL = "normal"
PREFER = "prefer"
AVOID = "avoid"
class LevelWayTypeChoice(StrEnum):
""" route preferences for way types that are level """
ALLOW = "allow"
AVOID = "avoid"
class AltitudeWayTypeChoice(StrEnum):
""" route preferences for way types that overcome a change in altitude """
ALLOW = "allow"
AVOID_UP = "avoid_up"
AVOID_DOWN = "avoid_down"
AVOID = "avoid"
class UpdateRouteOptionsSchema(BaseSchema):
# todo: default is wrong, this should be optional
mode: Union[
Annotated[RouteMode, APIField(title="route mode", description="routing mode to use")],
Annotated[None, APIField(title="null", description="don't change routing mode")],
] = APIField(
default=None,
title="routing mode",
)
walk_speed: Union[
Annotated[WalkSpeed, APIField(title="walk speed", description="walk speed to use")],
Annotated[None, APIField(title="null", description="don't change walk speed")],
] = APIField(
default=None,
title="walk speed",
)
restrictions: Union[
Annotated[RestrictedAreas, APIField(title="restricted areas",
description="whether to route through restricted areas")],
Annotated[None, APIField(title="null", description="don't change restricted areas routing")],
]
way_types: dict[
Annotated[NonEmptyStr, APIField(title="waytype")],
Union[
Annotated[LevelWayTypeChoice, APIField(title="waytype without altitude change")],
Annotated[AltitudeWayTypeChoice, APIField(title="waytype with altitude change")],
]
] = APIField(
default_factory=dict,
title="waytype settings",
)
class RouteOptionsSchema(BaseSchema):
# todo: default is wrong, this should be optional
mode: RouteMode = APIField(name="routing mode")
walk_speed: WalkSpeed = APIField(name="walk speed")
restrictions: RestrictedAreas = APIField(title="restricted areas")
way_types: dict[
Annotated[NonEmptyStr, APIField(title="waytype")],
Union[
Annotated[LevelWayTypeChoice, APIField(title="waytype without altitude change")],
Annotated[AltitudeWayTypeChoice, APIField(title="waytype with altitude change")],
]
] = APIField(
title="waytype settings",
)
class RouteParametersSchema(BaseSchema):
origin: AnyLocationID
destination: AnyLocationID
options_override: Optional[UpdateRouteOptionsSchema] = APIField(
None,
title="override routing options",
)
class ShortWayTypeSchema(DjangoModelSchema):
pass
class ShortSpaceSchema(DjangoModelSchema):
pass
class RouteLevelSchema(DjangoModelSchema):
on_top_of: Union[
Annotated[PositiveInt, APIField(title="level ID", description="level this level is on top of", example=1)],
Annotated[None, APIField(title="null", description="this is a main level, not on top of any other")]
] = APIField(
title="on top of level ID",
description="if set, this is not a main level, but it's on top of this other level"
)
class RouteItemSchema(BaseSchema):
id: PositiveInt
coordinates: Coordinates3D
waytype: Union[
Annotated[ShortWayTypeSchema, APIField(title="waytype", descripiton="waytype used for this segment")],
Annotated[None, APIField(title="null", description="no waytype (normal walking)")],
] = APIField(None, title="waytype")
space: Union[
Annotated[
ShortSpaceSchema,
APIField(title="space", descripiton="new space that is being entered")
],
Annotated[None, APIField(title="null", description="staying in the same space")],
] = APIField(None, description="new space being entered")
level: Union[
Annotated[
RouteLevelSchema,
APIField(title="level", descripiton="new level that is being entered")
],
Annotated[None, APIField(title="null", description="staying in the same level")],
] = APIField(None, description="new level being entered")
descriptions: list[tuple[
Annotated[NonEmptyStr, APIField(
title="icon name",
description="any material design icon name or svg icon file name"
)],
Annotated[NonEmptyStr, APIField(
title="instruction",
description="navigation instruction"
)]
]]
class RouteSchema(BaseSchema):
origin: SlimLocationSchema # todo: is this fine? works? no issues?
destination: SlimLocationSchema # todo: is this fine? works? no issues?
distance: float
duration: int
distance_str: NonEmptyStr
duration_str: NonEmptyStr
summary: NonEmptyStr
options_summary: NonEmptyStr
items: list[RouteItemSchema]
class RouteResponse(BaseSchema):
request: RouteParametersSchema
options: RouteOptionsSchema
options_form: Any
report_issue_url: NonEmptyStr
result: RouteSchema
class Config(Schema.Config):
title = "route found"
class NoRouteResponse(BaseSchema):
request: RouteParametersSchema
options: RouteOptionsSchema
error: NonEmptyStr = APIField(
name="error description",
description=("the routing parameters were valid, but it was not possible to determine a route. "
"this field contains the reason.")
)
class Config(Schema.Config):
title = "route could not be determined"
def get_request_pk(location):
return location.slug if isinstance(location, Position) else location.pk
@routing_api_router.post('/route/', summary="query route", auth=APIKeyAuth(is_readonly=True),
description="query route between two locations",
response={200: RouteResponse | NoRouteResponse, **validate_responses, **auth_responses})
# todo: route failure responses
def get_route(request, parameters: RouteParametersSchema):
form = RouteForm({
"origin": parameters.origin,
"destination": parameters.destination,
}, request=request)
if not form.is_valid():
return APIRequestValidationFailed("\n".join(form.errors))
options = RouteOptions.get_for_request(request)
if parameters.options_override is not None:
_new_update_route_options(options, parameters.options_override)
visible_locations = visible_locations_for_request(request)
try:
route = Router.load().get_route(origin=form.cleaned_data['origin'],
destination=form.cleaned_data['destination'],
permissions=AccessPermission.get_for_request(request),
options=options,
visible_locations=visible_locations)
except NotYetRoutable:
return NoRouteResponse(
request=parameters,
options=_new_serialize_route_options(options),
error=_('Not yet routable, try again shortly.'),
)
except LocationUnreachable:
return NoRouteResponse(
request=parameters,
options=_new_serialize_route_options(options),
error=_('Unreachable location.')
)
except NoRouteFound:
return NoRouteResponse(
request=parameters,
options=_new_serialize_route_options(options),
error=_('No route found.')
)
origin_values = api_stats_clean_location_value(form.cleaned_data['origin'].pk)
destination_values = api_stats_clean_location_value(form.cleaned_data['destination'].pk)
increment_cache_key('apistats__route')
for origin_value in origin_values:
for destination_value in destination_values:
increment_cache_key('apistats__route_tuple_%s_%s' % (origin_value, destination_value))
for value in origin_values:
increment_cache_key('apistats__route_origin_%s' % value)
for value in destination_values:
increment_cache_key('apistats__route_destination_%s' % value)
return RouteResponse(
request=parameters,
options=_new_serialize_route_options(options),
options_form=options.serialize(),
report_issue_url=reverse('site.report_start', kwargs={
'origin': parameters.origin,
'destination': parameters.destination,
'options': options.serialize_string(),
}),
result=route,
)
if settings.METRICS:
from c3nav.mapdata.metrics import APIStatsCollector
APIStatsCollector.add_stat('route')
APIStatsCollector.add_stat('route_tuple', ['origin', 'destination'])
APIStatsCollector.add_stat('route_origin', ['origin'])
APIStatsCollector.add_stat('route_destination', ['destination'])
def _new_serialize_route_options(options):
# todo: RouteOptions should obviously be modernized
main_options = {}
waytype_options = {}
for key, value in options.items():
if key.startswith("waytype_"):
waytype_options[key.removeprefix("waytype_")] = value
else:
main_options[key] = value
return {
**main_options,
"way_types": waytype_options,
}
def _new_update_route_options(options, new_options):
convert_options = new_options.dict()
waytype_options = convert_options.pop("way_types", {})
convert_options.update({f"waytype_{key}": value for key, value in waytype_options.items()})
try:
options.update(convert_options, ignore_unknown=True)
except ValidationError as e:
raise APIRequestValidationFailed(str(e))
@routing_api_router.get('/options/', summary="current route options",
description="get current preferred route options for this user (or session, if signed out)",
response={200: RouteOptionsSchema, **auth_responses})
def get_route_options(request):
# todo: API key should not override for user
options = RouteOptions.get_for_request(request)
return _new_serialize_route_options(options)
@routing_api_router.put('/options/', summary="set route options",
description="set current preferred route options for this user (or session, if signed out)",
response={200: RouteOptionsSchema, **validate_responses, **auth_responses})
def set_route_options(request, new_options: UpdateRouteOptionsSchema):
options = RouteOptions.get_for_request(request)
_new_update_route_options(options, new_options)
options.save()
return _new_serialize_route_options(options)
class RouteOptionsFieldChoices(BaseSchema):
name: NonEmptyStr
title: NonEmptyStr
class RouteOptionsField(BaseSchema):
name: NonEmptyStr
type: NonEmptyStr
label: NonEmptyStr
choices: list[RouteOptionsFieldChoices]
value: NonEmptyStr
value_display: NonEmptyStr
@routing_api_router.get('/options/form/', summary="get route options form",
description="get description of all form options, to render like a form (like old API)",
response={200: list[RouteOptionsField], **auth_responses})
def get_route_options_form(request):
options = RouteOptions.get_for_request(request)
data = options.serialize()
for option in data:
if option["name"].startswith("waytype_"):
option["name"] = "way_types."+data["name"].removeprefix("waytype_")
return data