From 846101ea3760d3a2b292e85dde6d955d3f54ff95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Sun, 19 Nov 2023 16:36:46 +0100 Subject: [PATCH] add more mapdata API endpoints --- src/c3nav/mapdata/models/geometry/space.py | 4 +- src/c3nav/mapdata/models/locations.py | 17 ++- src/c3nav/mapdata/newapi/mapdata.py | 116 ++++++++++++++- src/c3nav/mapdata/schemas/filters.py | 20 ++- src/c3nav/mapdata/schemas/model_base.py | 1 + src/c3nav/mapdata/schemas/models.py | 159 ++++++++++++++++++++- 6 files changed, 300 insertions(+), 17 deletions(-) diff --git a/src/c3nav/mapdata/models/geometry/space.py b/src/c3nav/mapdata/models/geometry/space.py index 0455efcb..fced7240 100644 --- a/src/c3nav/mapdata/models/geometry/space.py +++ b/src/c3nav/mapdata/models/geometry/space.py @@ -326,7 +326,7 @@ class LeaveDescription(SerializableMixin): result = super()._serialize(**kwargs) result['space'] = self.space_id result['target_space'] = self.target_space_id - result['description_i18n'] = self.description_i18n + result['descriptions'] = self.description_i18n result['description'] = self.description return result @@ -366,7 +366,7 @@ class CrossDescription(SerializableMixin): result['space'] = self.space_id result['origin_space'] = self.origin_space_id result['target_space'] = self.target_space_id - result['description_i18n'] = self.description_i18n + result['descriptions'] = self.description_i18n result['description'] = self.description return result diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py index a2dfc0d1..d06be651 100644 --- a/src/c3nav/mapdata/models/locations.py +++ b/src/c3nav/mapdata/models/locations.py @@ -293,9 +293,21 @@ class LocationGroupCategory(SerializableMixin, models.Model): def _serialize(self, detailed=True, **kwargs): result = super()._serialize(detailed=detailed, **kwargs) result['name'] = self.name + result['single'] = self.single if detailed: result['titles'] = self.titles - result['title'] = self.title + result['titles_plural'] = self.titles_plural + result['help_texts'] = self.help_texts + result['title'] = str(self.title) + result['title_plural'] = str(self.title_plural) + result['help_text'] = str(self.help_text) + result['allow_levels'] = self.allow_levels + result['allow_spaces'] = self.allow_spaces + result['allow_areas'] = self.allow_areas + result['allow_pois'] = self.allow_pois + result['allow_dynamic_locations'] = self.allow_dynamic_locations + result['priority'] = self.priority + return result def register_changed_geometries(self): @@ -352,6 +364,9 @@ class LocationGroup(Location, models.Model): def _serialize(self, simple_geometry=False, **kwargs): result = super()._serialize(simple_geometry=simple_geometry, **kwargs) result['category'] = self.category_id + result['priority'] = self.priority + result['hierarchy'] = self.hierarchy + result['can_report_missing'] = self.can_report_missing result['color'] = self.color if simple_geometry: result['locations'] = tuple(obj.pk for obj in getattr(self, 'locations', ())) diff --git a/src/c3nav/mapdata/newapi/mapdata.py b/src/c3nav/mapdata/newapi/mapdata.py index bc2fac92..1ba062a2 100644 --- a/src/c3nav/mapdata/newapi/mapdata.py +++ b/src/c3nav/mapdata/newapi/mapdata.py @@ -7,13 +7,17 @@ from ninja.pagination import paginate from c3nav.api.exceptions import API404 from c3nav.mapdata.api import optimize_query -from c3nav.mapdata.models import Area, Building, Door, Hole, Level, Space, Stair +from c3nav.mapdata.models import (Area, Building, Door, Hole, Level, LocationGroup, LocationGroupCategory, Source, + Space, Stair) from c3nav.mapdata.models.access import AccessPermission -from c3nav.mapdata.models.geometry.space import POI, Column, LineObstacle, Obstacle, Ramp -from c3nav.mapdata.schemas.filters import ByGroupFilter, ByLevelFilter, ByOnTopOfFilter, BySpaceFilter, FilterSchema -from c3nav.mapdata.schemas.models import (AreaSchema, BuildingSchema, ColumnSchema, DoorSchema, HoleSchema, LevelSchema, - LineObstacleSchema, ObstacleSchema, POISchema, RampSchema, SpaceSchema, - StairSchema) +from c3nav.mapdata.models.geometry.space import (POI, Column, CrossDescription, LeaveDescription, LineObstacle, + Obstacle, Ramp) +from c3nav.mapdata.schemas.filters import (ByCategoryFilter, ByGroupFilter, ByLevelFilter, ByOnTopOfFilter, + BySpaceFilter, FilterSchema) +from c3nav.mapdata.schemas.models import (AreaSchema, BuildingSchema, ColumnSchema, CrossDescriptionSchema, DoorSchema, + HoleSchema, LeaveDescriptionSchema, LevelSchema, LineObstacleSchema, + LocationGroupCategorySchema, LocationGroupSchema, ObstacleSchema, POISchema, + RampSchema, SourceSchema, SpaceSchema, StairSchema) mapdata_api_router = APIRouter(tags=["mapdata"]) @@ -316,3 +320,103 @@ def poi_list(request, filters: Query[BySpaceFilter]): def poi_detail(request, poi_id: int): # todo: access, caching, filtering, etc return mapdata_retrieve_endpoint(request, POI, pk=poi_id) + + +""" +LeaveDescriptions +""" + + +@mapdata_api_router.get('/leavedescriptions/', response=list[LeaveDescriptionSchema], + summary="Get leave description list") +@paginate +def leavedescription_list(request, filters: Query[BySpaceFilter]): + # todo cache? + return mapdata_list_endpoint(request, model=LeaveDescription, filters=filters) + + +@mapdata_api_router.get('/leavedescriptions/{leavedescription_id}/', response=LeaveDescriptionSchema, + summary="Get leave description by ID") +def leavedescription_detail(request, leavedescription_id: int): + # todo: access, caching, filtering, etc + return mapdata_retrieve_endpoint(request, LeaveDescription, pk=leavedescription_id) + + +""" +CrossDescriptions +""" + + +@mapdata_api_router.get('/crossdescriptions/', response=list[CrossDescriptionSchema], + summary="Get cross description list") +@paginate +def crossdescription_list(request, filters: Query[BySpaceFilter]): + # todo cache? + return mapdata_list_endpoint(request, model=CrossDescription, filters=filters) + + +@mapdata_api_router.get('/crossdescriptions/{crossdescription_id}/', response=CrossDescriptionSchema, + summary="Get cross description by ID") +def crossdescription_detail(request, crossdescription_id: int): + # todo: access, caching, filtering, etc + return mapdata_retrieve_endpoint(request, CrossDescription, pk=crossdescription_id) + + +""" +LocationGroup +""" + + +@mapdata_api_router.get('/locationgroups/', response=list[LocationGroupSchema], + summary="Get location group list") +@paginate +def locationgroup_list(request, filters: Query[ByCategoryFilter]): + # todo cache? + return mapdata_list_endpoint(request, model=LocationGroup, filters=filters) + + +@mapdata_api_router.get('/locationgroups/{locationgroup_id}/', response=LocationGroupSchema, + summary="Get location group by ID") +def locationgroup_detail(request, locationgroup_id: int): + # todo: access, caching, filtering, etc + return mapdata_retrieve_endpoint(request, LocationGroup, pk=locationgroup_id) + + +""" +LocationGroupCategories +""" + + +@mapdata_api_router.get('/locationgroupcategories/', response=list[LocationGroupCategorySchema], + summary="Get location group category list") +@paginate +def locationgroupcategory_list(request): + # todo cache? + return mapdata_list_endpoint(request, model=LocationGroupCategory) + + +@mapdata_api_router.get('/locationgroupcategories/{category_id}/', response=LocationGroupCategorySchema, + summary="Get location group category by ID") +def locationgroupcategory_detail(request, category_id: int): + # todo: access, caching, filtering, etc + return mapdata_retrieve_endpoint(request, LocationGroupCategory, pk=category_id) + + +""" +Sources +""" + + +@mapdata_api_router.get('/sources/', response=list[SourceSchema], + summary="Get source list") +@paginate +def source_list(request): + # todo cache? + return mapdata_list_endpoint(request, model=Source) + + +@mapdata_api_router.get('/sources/{source_id}/', response=SourceSchema, + summary="Get source by ID") +def source_detail(request, source_id: int): + # todo: access, caching, filtering, etc + return mapdata_retrieve_endpoint(request, Source, pk=source_id) diff --git a/src/c3nav/mapdata/schemas/filters.py b/src/c3nav/mapdata/schemas/filters.py index 10f1dcd9..0edf7d88 100644 --- a/src/c3nav/mapdata/schemas/filters.py +++ b/src/c3nav/mapdata/schemas/filters.py @@ -6,7 +6,7 @@ from ninja import Schema from pydantic import Field as APIField from c3nav.api.exceptions import APIRequestValidationFailed -from c3nav.mapdata.models import Level, LocationGroup, MapUpdate, Space +from c3nav.mapdata.models import Level, LocationGroup, LocationGroupCategory, MapUpdate, Space from c3nav.mapdata.models.access import AccessPermission @@ -82,6 +82,24 @@ class BySpaceFilter(FilterSchema): return super().filter_qs(qs) +class ByCategoryFilter(FilterSchema): + category: Optional[int] = APIField( + None, + title="filter by location group category", + description="if set, only groups belonging to the location group category with this ID will be shown" + ) + + def validate(self, request): + super().validate(request) + if self.category is not None: + assert_valid_value(request, LocationGroupCategory, "pk", {self.category}) + + def filter_qs(self, qs: QuerySet) -> QuerySet: + if self.category is not None: + qs = qs.filter(category=self.category) + return super().filter_qs(qs) + + class ByGroupFilter(FilterSchema): group: Optional[int] = APIField( None, diff --git a/src/c3nav/mapdata/schemas/model_base.py b/src/c3nav/mapdata/schemas/model_base.py index 53e41359..242d7e01 100644 --- a/src/c3nav/mapdata/schemas/model_base.py +++ b/src/c3nav/mapdata/schemas/model_base.py @@ -116,6 +116,7 @@ class SpecificLocationSchema(LocationSchema): label_settings: Optional[LabelSettingsSchema] = APIField( default=None, title="label settings", + description="if not set, it may be taken from location groups" ) label_override: Optional[NonEmptyStr] = APIField( default=None, diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py index b4f0d3d6..dab69bbf 100644 --- a/src/c3nav/mapdata/schemas/models.py +++ b/src/c3nav/mapdata/schemas/models.py @@ -2,10 +2,10 @@ from typing import Optional from pydantic import Field as APIField from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt -from pydantic.color import Color from c3nav.api.utils import NonEmptyStr -from c3nav.mapdata.schemas.model_base import (AccessRestrictionSchema, DjangoModelSchema, SpecificLocationSchema, +from c3nav.mapdata.schemas.model_base import (AccessRestrictionSchema, DjangoModelSchema, LabelSettingsSchema, + LocationSchema, SerializableSchema, SpecificLocationSchema, TitledSchema, WithLevelSchema, WithLineStringGeometrySchema, WithPointGeometrySchema, WithPolygonGeometrySchema, WithSpaceSchema) @@ -14,7 +14,7 @@ class LevelSchema(SpecificLocationSchema, DjangoModelSchema): """ A physical level of the map, containing building, spaces, doors… - A level is a specific location, and can therefor be routed to and from, as well as belong to location groups. + A level is a specific location, and can therefore be routed to and from, as well as belong to location groups. """ short_label: NonEmptyStr = APIField( title="short label (for level selector)", @@ -46,7 +46,7 @@ class SpaceSchema(WithPolygonGeometrySchema, SpecificLocationSchema, WithLevelSc """ An accessible area on a level. It can be outside-only or inside-only. - A space is a specific location, and can therefor be routed to and from, as well as belong to location groups. + A space is a specific location, and can therefore be routed to and from, as well as belong to location groups. """ outside: bool = APIField( title="outside only", @@ -76,7 +76,7 @@ class AreaSchema(WithPolygonGeometrySchema, SpecificLocationSchema, WithSpaceSch """ An area inside a space. - An area is a specific location, and can therefor be routed to and from, as well as belong to location groups. + An area is a specific location, and can therefore be routed to and from, as well as belong to location groups. """ slow_down_factor: PositiveFloat = APIField( title="slow-down factor", @@ -107,7 +107,7 @@ class BaseObstacleSchema(WithSpaceSchema, DjangoModelSchema): title="altitude above ground", description="altitude above ground" ) - color: Optional[Color] = APIField( + color: Optional[NonEmptyStr] = APIField( title="color", description="an optional color for this obstacle" ) @@ -141,6 +141,151 @@ class POISchema(WithPointGeometrySchema, SpecificLocationSchema, WithSpaceSchema """ A point of interest inside a space. - A POI is a specific location, and can therefor be routed to and from, as well as belong to location groups. + A POI is a specific location, and can therefore be routed to and from, as well as belong to location groups. """ pass + + +class LeaveDescriptionSchema(WithSpaceSchema, DjangoModelSchema): + """ + A description for leaving a space to enter another space. + """ + target_space: PositiveInt = APIField( + title="target space", + description="the space that is being entered", + ) + descriptions: dict[NonEmptyStr, NonEmptyStr] = APIField( + title="description (all languages)", + description="property names are the ISO-language code. languages may be missing.", + example={ + "en": "Stanley walked through the red door.", + "de": "Stanley ging durch die rote Tür.", + } + ) + description: NonEmptyStr = APIField( + title="description (preferred language)", + description="preferred language based on the Accept-Language header." + ) + + +class CrossDescriptionSchema(WithSpaceSchema, DjangoModelSchema): + """ + A description for crossing through a space from one space to another. + """ + origin_space: PositiveInt = APIField( + title="origin space", + description="the space from which the main space is being entered", + ) + target_space: PositiveInt = APIField( + title="target space", + description="the space that is being entered from the main space", + ) + descriptions: dict[NonEmptyStr, NonEmptyStr] = APIField( + title="description (all languages)", + description="property names are the ISO-language code. languages may be missing.", + example={ + "en": "Go straight ahead through the big glass doors.", + "de": "gehe geradeaus durch die Glastüren.", + } + ) + description: NonEmptyStr = APIField( + title="description (preferred language)", + description="preferred language based on the Accept-Language header." + ) + + +class LocationGroupSchema(LocationSchema, DjangoModelSchema): + """ + A location group, always belonging to a location group category. + + A location group is a (non-specific) location, which means it can be routed to and from. + """ + category: PositiveInt = APIField( + title="category", + description="location group category that this location group belongs to", + ) + priority: int = APIField() # todo: ??? + hierarchy: int = APIField() # todo: ??? + label_settings: Optional[LabelSettingsSchema] = APIField( + default=None, + title="label settings", + description="for locations with this group, can be overwritten by specific locations" + ) + can_report_missing: bool = APIField( + title="report missing locations", + description="can be used in form for reporting missing locations", + ) + color: Optional[NonEmptyStr] = APIField( + title="color", + description="an optional color for spaces and areas with this group" + ) + + +class LocationGroupCategorySchema(TitledSchema, SerializableSchema, DjangoModelSchema): + """ + A location group category can hold either one or multiple location groups. + + It is used to allow for having different kind of groups for different means. + """ + name: NonEmptyStr = APIField( + title="name/slug", + description="name/slug of this location group category", + ) + single: bool = APIField( + title="single choice", + description="if true, every location can only have one group from this category, not a list" + ) + titles_plural: dict[NonEmptyStr, NonEmptyStr] = APIField( + title="plural title (all languages)", + description="property names are the ISO-language code. languages may be missing.", + example={ + "en": "Title", + "de": "Titel", + } + ) + title_plural: NonEmptyStr = APIField( + title="plural title (preferred language)", + description="preferred language based on the Accept-Language header." + ) + help_texts: dict[NonEmptyStr, NonEmptyStr] = APIField( + title="help text (all languages)", + description="property names are the ISO-language code. languages may be missing.", + example={ + "en": "Title", + "de": "Titel", + } + ) + help_text: str = APIField( + title="help text (preferred language)", + description="preferred language based on the Accept-Language header." + ) + allow_levels: bool = APIField( + description="whether groups with this category can be assigned to levels" + ) + allow_spaces: bool = APIField( + description="whether groups with this category can be assigned to spaces" + ) + allow_areas: bool = APIField( + description="whether groups with this category can be assigned to areas" + ) + allow_pois: bool = APIField( + description="whether groups with this category can be assigned to POIs" + ) + allow_dynamic_locations: bool = APIField( + description="whether groups with this category can be assigned to dynamic locations" + ) + priority: int = APIField() # todo: ??? + + +class SourceSchema(AccessRestrictionSchema, DjangoModelSchema): + """ + A source image that can be traced in the editor. + """ + name: NonEmptyStr = APIField( + title="name", + description="name/filename of the source", + ) + bottom: float + left: float + top: float + right: float