diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py index a21e81af..3723d81d 100644 --- a/src/c3nav/mapdata/models/locations.py +++ b/src/c3nav/mapdata/models/locations.py @@ -536,6 +536,8 @@ class DynamicLocation(CustomLocationProxyMixin, SpecificLocation, models.Model): return { 'available': False, 'id': self.pk, + 'slug': self.slug, + 'icon': self.get_icon(), 'title': self.title, 'subtitle': '%s %s, %s' % (_('currently unavailable'), _('(moving)'), self.subtitle) } diff --git a/src/c3nav/mapdata/newapi/map.py b/src/c3nav/mapdata/newapi/map.py index 2a0b9421..a06ae9b5 100644 --- a/src/c3nav/mapdata/newapi/map.py +++ b/src/c3nav/mapdata/newapi/map.py @@ -3,6 +3,7 @@ from typing import Annotated, Union from django.core.cache import cache from django.core.serializers.json import DjangoJSONEncoder +from django.http import Http404 from django.shortcuts import redirect from ninja import Query from ninja import Router as APIRouter @@ -15,10 +16,11 @@ from c3nav.api.newauth import auth_responses, validate_responses from c3nav.api.utils import NonEmptyStr from c3nav.mapdata.models import Source from c3nav.mapdata.models.access import AccessPermission -from c3nav.mapdata.models.locations import LocationRedirect +from c3nav.mapdata.models.locations import DynamicLocation, LocationRedirect, Position from c3nav.mapdata.schemas.filters import BySearchableFilter, RemoveGeometryFilter -from c3nav.mapdata.schemas.model_base import LocationID -from c3nav.mapdata.schemas.models import FullLocationSchema, LocationDisplay, SlimLocationSchema +from c3nav.mapdata.schemas.model_base import AnyLocationID, AnyPositionID +from c3nav.mapdata.schemas.models import (AnyPositionStatusSchema, FullListableLocationSchema, FullLocationSchema, + LocationDisplay, SlimListableLocationSchema, SlimLocationSchema) from c3nav.mapdata.schemas.responses import BoundsSchema from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_location_by_slug_for_request, searchable_locations_for_request, visible_locations_for_request) @@ -74,14 +76,14 @@ def _location_list(request, detailed: bool, filters: LocationListFilters): @map_api_router.get('/locations/', - response={200: list[SlimLocationSchema], **validate_responses, **auth_responses}, + response={200: list[SlimListableLocationSchema], **validate_responses, **auth_responses}, summary="Get locations (with most important attributes)") def location_list(request, filters: Query[LocationListFilters]): return _location_list(request, detailed=False, filters=filters) @map_api_router.get('/locations/full/', - response={200: list[FullLocationSchema], **validate_responses, **auth_responses}, + response={200: list[FullListableLocationSchema], **validate_responses, **auth_responses}, summary="Get locations (with all attributes)") def location_list_full(request, filters: Query[LocationListFilters]): return _location_list(request, detailed=True, filters=filters) @@ -134,7 +136,7 @@ class ShowRedirects(Schema): response={200: SlimLocationSchema, **API404.dict(), **validate_responses, **auth_responses}, summary="Get location by ID (with most important attributes)", description="a numeric ID for a map location or a string ID for generated locations can be used") -def location_by_id(request, location_id: LocationID, filters: Query[RemoveGeometryFilter], +def location_by_id(request, location_id: AnyLocationID, filters: Query[RemoveGeometryFilter], redirects: Query[ShowRedirects]): return _location_retrieve( request, @@ -147,7 +149,7 @@ def location_by_id(request, location_id: LocationID, filters: Query[RemoveGeomet response={200: FullLocationSchema, **API404.dict(), **validate_responses, **auth_responses}, summary="Get location by ID (with all attributes)", description="a numeric ID for a map location or a string ID for generated locations can be used") -def location_by_id_full(request, location_id: LocationID, filters: Query[RemoveGeometryFilter], +def location_by_id_full(request, location_id: AnyLocationID, filters: Query[RemoveGeometryFilter], redirects: Query[ShowRedirects]): return _location_retrieve( request, @@ -160,14 +162,13 @@ def location_by_id_full(request, location_id: LocationID, filters: Query[RemoveG response={200: LocationDisplay, **API404.dict(), **auth_responses}, summary="Get location display data by ID", description="a numeric ID for a map location or a string ID for generated locations can be used") -def location_by_id_display(request, location_id: LocationID): +def location_by_id_display(request, location_id: AnyLocationID): return _location_display( request, get_location_by_id_for_request(location_id, request), ) - @map_api_router.get('/locations/by-slug/{location_slug}/', response={200: SlimLocationSchema, **API404.dict(), **validate_responses, **auth_responses}, summary="Get location by slug (with most important attributes)") @@ -200,3 +201,20 @@ def location_by_slug_display(request, location_slug: NonEmptyStr): request, get_location_by_slug_for_request(location_slug, request), ) + + +@map_api_router.get('/get_position/{position_id}/', + response={200: AnyPositionStatusSchema, **API404.dict(), **auth_responses}, + summary="a numeric ID for a dynamic location or a string ID for the position secret can be used") +def get_current_position_by_id(request, position_id: AnyPositionID): + location = None + if isinstance(position_id, int) or position_id.isdigit(): + location = get_location_by_id_for_request(position_id, request) + if not isinstance(location, DynamicLocation): + raise Http404 + if location is None and position_id.startswith('p:'): + try: + location = Position.objects.get(secret=position_id[2:]) + except Position.DoesNotExist: + raise Http404 + return location.serialize_position() diff --git a/src/c3nav/mapdata/schemas/model_base.py b/src/c3nav/mapdata/schemas/model_base.py index b6348ddc..304f9a77 100644 --- a/src/c3nav/mapdata/schemas/model_base.py +++ b/src/c3nav/mapdata/schemas/model_base.py @@ -192,19 +192,30 @@ class SimpleGeometryLocationsSchema(Schema): ) -LocationID = Union[ +CustomLocationID = Annotated[NonEmptyStr, APIField( + title="custom location ID", + pattern=r"c:[a-z0-9-_]+:(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)$", + description="level short_name and x/y coordinates form the ID of a custom location" +)] +PositionID = Annotated[NonEmptyStr, APIField( + title="position ID", + pattern=r"p:[a-z0-9]+$", + description="the ID of a user-defined tracked position is made up of its secret" +)] + + +AnyLocationID = Union[ Annotated[PositiveInt, APIField( title="location ID", description="numeric ID of any lcation" )], - Annotated[NonEmptyStr, APIField( - title="custom location ID", - pattern=r"c:[a-z0-9-_]+:(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)$", - description="level short_name and x/y coordinates form the ID of a custom location" - )], - Annotated[NonEmptyStr, APIField( - title="position ID", - pattern=r"p:[a-z0-9]+$", - description="the ID of a user-defined tracked position is made up of its secret" - )], + CustomLocationID, + PositionID, +] +AnyPositionID = Union[ + Annotated[PositiveInt, APIField( + title="dynamic location ID", + description="numeric ID of any dynamic lcation" + )], + PositionID, ] diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py index 9f782285..8f48b72d 100644 --- a/src/c3nav/mapdata/schemas/models.py +++ b/src/c3nav/mapdata/schemas/models.py @@ -3,14 +3,12 @@ from typing import Annotated, ClassVar, Literal, Optional, Union from ninja import Schema from pydantic import Discriminator from pydantic import Field as APIField -from pydantic import GetJsonSchemaHandler, NonNegativeFloat, PositiveFloat, PositiveInt -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import CoreSchema +from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt -from c3nav.api.schema import GeometrySchema +from c3nav.api.schema import GeometrySchema, PointSchema from c3nav.api.utils import NonEmptyStr -from c3nav.mapdata.schemas.model_base import (DjangoModelSchema, LabelSettingsSchema, LocationID, LocationSchema, - LocationSlugSchema, SerializableSchema, +from c3nav.mapdata.schemas.model_base import (AnyLocationID, AnyPositionID, CustomLocationID, DjangoModelSchema, + LabelSettingsSchema, LocationSchema, PositionID, SerializableSchema, SimpleGeometryBoundsAndPointSchema, SimpleGeometryBoundsSchema, SimpleGeometryLocationsSchema, SpecificLocationSchema, TitledSchema, WithAccessRestrictionSchema, WithLevelSchema, @@ -328,6 +326,88 @@ class AccessRestrictionGroupSchema(WithAccessRestrictionSchema, DjangoModelSchem pass +class CustomLocationSchema(SerializableSchema): + """ + A custom location represents coordinates that have been put in or calculated. + + A custom location is a location, so it can be routed to and from. + """ + id: CustomLocationID = APIField( + description="ID representing the coordinates" + ) + slug: CustomLocationID = APIField( + description="slug, identical to ID" + ) + icon: Optional[NonEmptyStr] = APIField( + default=None, + title="icon name", + description="any material design icon name" + ) + title: NonEmptyStr = APIField( + title="title (preferred language)", + description="preferred language based on the Accept-Language header." + ) + subtitle: NonEmptyStr = APIField( + title="subtitle (preferred language)", + description="an automatically generated short description for this location. " + "preferred language based on the Accept-Language header." + ) + level: PositiveInt = APIField( + description="level ID this custom location is located on" + ) + space: Optional[PositiveInt] = APIField( + description="space ID this custom location is located in, if applicable" + ) + areas: list[PositiveInt] = APIField( + description="IDs of areas this custom location is located in" + ) + grid_square: Optional[NonEmptyStr] = APIField( + default=None, + title="grid square", + description="if a grid is defined and this custom location is within it", + ) + near_area: Optional[PositiveInt] = APIField( + description="the ID of an area near this custom location, if there is one" + ) + near_poi: Optional[PositiveInt] = APIField( + description="the ID of a POI near this custom location, if there is one" + ) + neaby: list[PositiveInt] = APIField( + description="list of IDs of nearby locations" + ) + altitude: Optional[float] = APIField( + description="ground altitude (in the map-wide coordinate system), if it can be determined" + ) + geometry: Optional[PointSchema] = APIField( + description="point geometry for this custom location", + ) + + +class TrackablePositionSchema(Schema): + """ + A trackable position. It's position can be set or reset. + """ + id: PositionID = APIField( + description="ID representing the position" + ) + slug: PositionID = APIField( + description="slug representing the position" + ) + icon: Optional[NonEmptyStr] = APIField( + default=None, + title="icon name", + description="any material design icon name" + ) + title: NonEmptyStr = APIField( + title="title of the position", + ) + subtitle: NonEmptyStr = APIField( + title="subtitle (preferred language)", + description="an automatically generated short description, which might change when the position changes. " + "preferred language based on the Accept-Language header." + ) + + class FullLevelLocationSchema(LevelSchema): """ A level for the location API. @@ -376,6 +456,22 @@ class FullDynamicLocationLocationSchema(SimpleGeometryLocationsSchema, DynamicLo locationtype: Literal["dynamiclocation"] +class CustomLocationLocationSchema(SimpleGeometryBoundsAndPointSchema, CustomLocationSchema): + """ + A custom location for the location API. + See CustomLocation schema for details. + """ + locationtype: Literal["customlocation"] + + +class TrackablePositionLocationSchema(TrackablePositionSchema): + """ + A trackable position for the location API. + See TrackablePosition schema for details. + """ + locationtype: Literal["position"] + + class SlimLocationMixin(Schema): level: ClassVar[None] space: ClassVar[None] @@ -443,7 +539,7 @@ class SlimDynamicLocationLocationSchema(SlimLocationMixin, FullDynamicLocationLo pass -FullLocationSchema = Annotated[ +FullListableLocationSchema = Annotated[ Union[ FullLevelLocationSchema, FullSpaceLocationSchema, @@ -455,7 +551,16 @@ FullLocationSchema = Annotated[ Discriminator("locationtype"), ] -SlimLocationSchema = Annotated[ +FullLocationSchema = Annotated[ + Union[ + FullListableLocationSchema, + CustomLocationLocationSchema, + TrackablePositionLocationSchema, + ], + Discriminator("locationtype"), +] + +SlimListableLocationSchema = Annotated[ Union[ SlimLevelLocationSchema, SlimSpaceLocationSchema, @@ -467,6 +572,15 @@ SlimLocationSchema = Annotated[ Discriminator("locationtype"), ] +SlimLocationSchema = Annotated[ + Union[ + SlimListableLocationSchema, + CustomLocationLocationSchema, + TrackablePositionLocationSchema, + ], + Discriminator("locationtype"), +] + class DisplayLink(Schema): """ @@ -479,7 +593,7 @@ class DisplayLink(Schema): class LocationDisplay(SerializableSchema): - id: LocationID = APIField( + id: AnyLocationID = APIField( description="a numeric ID for a map location or a string ID for generated locations", ) level: Optional[PositiveInt] = APIField( @@ -507,3 +621,29 @@ class LocationDisplay(SerializableSchema): editor_url: Optional[NonEmptyStr] = APIField( None, description="path to edit this object in the editor, if the user has access to it", ) + + +class PositionStatusSchema(Schema): + id: AnyPositionID = APIField( + description="the ID of the dynamic position that has been queries", + ) + slug: NonEmptyStr = APIField( + description="a description for the dynamic position that has been queried" + ) + + +class PositionUnavailableStatusSchema(PositionStatusSchema, TrackablePositionSchema): + available: Literal[False] + + +class PositionAvailableStatusSchema(PositionStatusSchema, TrackablePositionSchema, CustomLocationSchema): + available: Literal[True] + + +AnyPositionStatusSchema = Annotated[ + Union[ + Annotated[PositionUnavailableStatusSchema, APIField(title="position is unavailable")], + Annotated[PositionAvailableStatusSchema, APIField(title="position is available")], + ], + Discriminator("available"), +] diff --git a/src/c3nav/mapdata/utils/locations.py b/src/c3nav/mapdata/utils/locations.py index 475a5519..3d5ea65c 100644 --- a/src/c3nav/mapdata/utils/locations.py +++ b/src/c3nav/mapdata/utils/locations.py @@ -312,7 +312,8 @@ class CustomLocation: ('near_area', self.near_area.pk if self.near_area else None), ('near_poi', self.near_poi.pk if self.near_poi else None), ('nearby', tuple(location.pk for location in self.nearby)), - ('altitude', None if self.altitude is None else round(self.altitude, 2)) + ('altitude', None if self.altitude is None else round(self.altitude, 2)), + ('locationtype', 'customlocation'), )) if hasattr(self, 'score'): result['score'] = self.score