editor geometries now available in new API

This commit is contained in:
Laura Klünder 2023-12-03 16:37:05 +01:00
parent 70e9e1fb9f
commit 5c203a7a2b
12 changed files with 411 additions and 45 deletions

View file

@ -2,7 +2,7 @@ from django.conf import settings
from ninja import Router as APIRouter
from ninja import Schema
from c3nav.api.newauth import auth_responses, APIAuthMethod
from c3nav.api.newauth import APIAuthMethod, auth_responses
from c3nav.api.utils import NonEmptyStr
from c3nav.control.models import UserPermissions

View file

@ -1,4 +1,4 @@
from ninja import NinjaAPI, Swagger
from ninja import NinjaAPI, Redoc, Swagger
from ninja.operation import Operation
from ninja.schema import NinjaGenerateJsonSchema

View file

@ -18,7 +18,7 @@ from c3nav.api.utils import get_api_post_data
from c3nav.editor.forms import ChangeSetForm, RejectForm
from c3nav.editor.models import ChangeSet
from c3nav.editor.utils import LevelChildEditUtils, SpaceChildEditUtils
from c3nav.editor.views.base import etag_func
from c3nav.editor.views.base import editor_etag_func
from c3nav.mapdata.api import api_etag
from c3nav.mapdata.models import Area, MapUpdate, Source
from c3nav.mapdata.models.geometry.space import POI
@ -136,7 +136,7 @@ class EditorViewSet(EditorViewSetMixin, ViewSet):
# noinspection PyPep8Naming
@action(detail=False, methods=['get'])
@api_etag_with_update_cache_key(etag_func=etag_func, cache_parameters={'level': str, 'space': str})
@api_etag_with_update_cache_key(etag_func=editor_etag_func, cache_parameters={'level': str, 'space': str})
def geometries(self, request, update_cache_key, update_cache_key_match, *args, **kwargs):
Level = request.changeset.wrap_model('Level')
Space = request.changeset.wrap_model('Space')
@ -378,7 +378,7 @@ class EditorViewSet(EditorViewSetMixin, ViewSet):
})
@action(detail=False, methods=['get'])
@api_etag(etag_func=etag_func, cache_parameters={})
@api_etag(etag_func=editor_etag_func, cache_parameters={})
def bounds(self, request, *args, **kwargs):
return Response({
'bounds': Source.max_bounds(),

View file

@ -0,0 +1,30 @@
from functools import wraps
from c3nav.editor.models import ChangeSet
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.newapi.base import newapi_etag
def newapi_etag_with_update_cache_key(permissions=True, etag_func=AccessPermission.etag_func, base_mapdata=False):
def inner_wrapper(func):
func = newapi_etag(permissions=permissions, etag_func=etag_func, base_mapdata=base_mapdata)(func)
@wraps(func)
def inner_wrapped_func(request, *args, **kwargs):
try:
changeset = request.changeset
except AttributeError:
changeset = ChangeSet.get_for_request(request)
request.changeset = changeset
request_update_cache_key = kwargs.get("update_cache_key", None)
actual_update_cache_key = changeset.raw_cache_key_without_changes
kwargs.update({
"update_cache_key": actual_update_cache_key,
"update_cache_key_match": request_update_cache_key == actual_update_cache_key,
})
return func(request, *args, **kwargs)
return inner_wrapped_func
return inner_wrapper

View file

@ -1,26 +1,15 @@
from datetime import datetime
from typing import Annotated, Optional, Literal, Union
from django.db import IntegrityError, transaction
from ninja import Field as APIField
from ninja import Query
from ninja import Router as APIRouter
from ninja import Schema, UploadedFile
from ninja.pagination import paginate
from pydantic import PositiveInt, field_validator
from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed
from c3nav.api.newauth import APITokenAuth, auth_permission_responses, auth_responses, validate_responses
from c3nav.api.schema import GeometrySchema, LineSchema
from c3nav.api.utils import NonEmptyStr
from c3nav.editor.newapi.schemas import GeometryStylesSchema, EditorID, EditorSpaceGeometriesElemSchema, \
EditorLevelGeometriesElemSchema, UpdateCacheKey
from c3nav.api.exceptions import API404
from c3nav.api.newauth import APITokenAuth, auth_permission_responses
from c3nav.editor.newapi.base import newapi_etag_with_update_cache_key
from c3nav.editor.newapi.geometries import get_level_geometries_result, get_space_geometries_result
from c3nav.editor.newapi.schemas import (EditorID, EditorLevelGeometriesElemSchema, EditorSpaceGeometriesElemSchema,
GeometryStylesSchema, UpdateCacheKey)
from c3nav.editor.views.base import editor_etag_func
from c3nav.mapdata.models import Source
from c3nav.mapdata.newapi.base import newapi_etag
from c3nav.mapdata.schemas.responses import BoundsSchema
from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage
from c3nav.mesh.messages import MeshMessageType
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage
editor_api_router = APIRouter(tags=["editor"], auth=APITokenAuth(permissions={"editor_access"}))
@ -65,12 +54,22 @@ def geometrystyles():
response={200: list[EditorSpaceGeometriesElemSchema], **API404.dict(),
**auth_permission_responses},
openapi_extra={"security": [{"APITokenAuth": ["editor_access"]}]})
@newapi_etag() # todo: correct?
def space_geometries(space_id: EditorID, update_cache_key: UpdateCacheKey = None):
@newapi_etag_with_update_cache_key(etag_func=editor_etag_func) # todo: correct?
def space_geometries(request, space_id: EditorID, update_cache_key: UpdateCacheKey = None, **kwargs):
"""
look. this is a complex mess. there will hopefully be more documentation soon. or a better endpoint.
"""
raise NotImplementedError
# newapi_etag_with_update_cache_key does the following, don't let it confuse you:
# - update_cache_key becomes the actual update_cache_key, not the one supplied be the user
# - kwargs has "update_cache_key_match", which is true if update_cache_key matches the one supplied be the user
# this is done so the api etag is correctly generated, as it takes the function arguments into account
return get_space_geometries_result(
request,
space_id=space_id,
update_cache_key=update_cache_key,
update_cache_key_match=kwargs["update_cache_key_match"]
)
# todo: test the heck out of this
@editor_api_router.get('/geometries/level/{level_id}/', summary="get the geometries to display for a level",
@ -78,11 +77,21 @@ def space_geometries(space_id: EditorID, update_cache_key: UpdateCacheKey = None
**auth_permission_responses},
openapi_extra={"security": [{"APITokenAuth": ["editor_access"]}]})
@newapi_etag() # todo: correct?
def level_geometries(level_id: EditorID, update_cache_key: UpdateCacheKey = None):
def level_geometries(request, level_id: EditorID, update_cache_key: UpdateCacheKey = None, **kwargs):
"""
look. this is a complex mess. there will hopefully be more documentation soon. or a better endpoint.
"""
raise NotImplementedError
# newapi_etag_with_update_cache_key does the following, don't let it confuse you:
# - update_cache_key becomes the actual update_cache_key, not the one supplied be the user
# - kwargs has "update_cache_key_match", which is true if update_cache_key matches the one supplied be the user
# this is done so the api etag is correctly generated, as it takes the function arguments into account
return get_level_geometries_result(
request,
level_id=level_id,
update_cache_key=update_cache_key,
update_cache_key_match=kwargs["update_cache_key_match"]
)
# todo: test the heck out of this
# todo: need a way to pass the changeset if it's not a session API key

View file

@ -0,0 +1,323 @@
from dataclasses import dataclass
from itertools import chain
from typing import Sequence
from django.db.models import Prefetch, Q
from shapely import prepared
from shapely.lib import unary_union
from c3nav.api.exceptions import API404, APIPermissionDenied
from c3nav.editor.utils import LevelChildEditUtils, SpaceChildEditUtils
from c3nav.mapdata.utils.geometry import unwrap_geom
def space_sorting_func(space):
groups = tuple(space.groups.all())
if not groups:
return (0, 0, 0)
return (1, groups[0].category.priority, groups[0].hierarchy, groups[0].priority)
def _get_geometries_for_one_level(level):
buildings = level.buildings.all()
buildings_geom = unary_union([unwrap_geom(building.geometry) for building in buildings])
spaces = {space.pk: space for space in level.spaces.all()}
holes_geom = []
for space in spaces.values():
if space.outside:
space.geometry = space.geometry.difference(buildings_geom)
columns = [column.geometry for column in space.columns.all()]
if columns:
columns_geom = unary_union([unwrap_geom(column.geometry) for column in space.columns.all()])
space.geometry = space.geometry.difference(columns_geom)
holes = [unwrap_geom(hole.geometry) for hole in space.holes.all()]
if holes:
space_holes_geom = unary_union(holes)
holes_geom.append(space_holes_geom.intersection(unwrap_geom(space.geometry)))
space.geometry = space.geometry.difference(space_holes_geom)
for building in buildings:
building.original_geometry = building.geometry
if holes_geom:
holes_geom = unary_union(holes_geom)
holes_geom_prep = prepared.prep(holes_geom)
for obj in buildings:
if holes_geom_prep.intersects(unwrap_geom(obj.geometry)):
obj.geometry = obj.geometry.difference(holes_geom)
results = []
results.extend(buildings)
for door in level.doors.all():
results.append(door)
results.extend(sorted(spaces.values(), key=space_sorting_func))
return results
@dataclass(slots=True)
class LevelsForLevel:
levels: Sequence[int] # IDs of all levels to render for this level, in order, including the level itself
levels_on_top: Sequence[int] # IDs of levels that are on top of this level (on_top_of field)
levels_under: Sequence[int] # IDs of the level below this level plus levels on top of it (on_top_of field)
@classmethod
def for_level(cls, request, level: "Level", special_if_on_top=False): # add typing
# noinspection PyPep8Naming
Level = request.changeset.wrap_model('Level')
levels_under = ()
levels_on_top = ()
lower_level = level.lower(Level).first()
primary_levels = (level,) + ((lower_level,) if lower_level else ())
secondary_levels = Level.objects.filter(on_top_of__in=primary_levels).values_list('pk', 'on_top_of')
if lower_level:
levels_under = tuple(pk for pk, on_top_of in secondary_levels if on_top_of == lower_level.pk)
if True:
levels_on_top = tuple(pk for pk, on_top_of in secondary_levels if on_top_of == level.pk)
levels = tuple(chain([level.pk], levels_under, levels_on_top))
if special_if_on_top and level.on_top_of_id is not None:
levels = tuple(chain([level.pk], levels_on_top))
levels_under = (level.on_top_of_id, )
levels_on_top = ()
return cls(
levels=levels,
levels_under=levels_under,
levels_on_top=levels_on_top,
)
def area_sorting_func(area):
groups = tuple(area.groups.all())
if not groups:
return (0, 0, 0)
return (1, groups[0].category.priority, groups[0].hierarchy, groups[0].priority)
def conditional_geojson(obj, update_cache_key_match):
if update_cache_key_match and not obj._affected_by_changeset:
return obj.get_geojson_key()
result = obj.to_geojson(instance=obj)
result['properties']['changed'] = obj._affected_by_changeset
return result
# noinspection PyPep8Naming
def get_level_geometries_result(request, level_id: int, update_cache_key: str, update_cache_key_match: True):
Level = request.changeset.wrap_model('Level')
Space = request.changeset.wrap_model('Space')
Column = request.changeset.wrap_model('Column')
Hole = request.changeset.wrap_model('Hole')
AltitudeMarker = request.changeset.wrap_model('AltitudeMarker')
Building = request.changeset.wrap_model('Building')
Door = request.changeset.wrap_model('Door')
LocationGroup = request.changeset.wrap_model('LocationGroup')
WifiMeasurement = request.changeset.wrap_model('WifiMeasurement')
RangingBeacon = request.changeset.wrap_model('RangingBeacon')
try:
level = Level.objects.filter(Level.q_for_request(request)).get(pk=level_id)
except Level.DoesNotExist:
raise API404('Level not found')
edit_utils = LevelChildEditUtils(level, request) # todo: what's happening here?
if not edit_utils.can_access_child_base_mapdata:
raise APIPermissionDenied()
levels_for_level = LevelsForLevel.for_level(request, level)
# don't prefetch groups for now as changesets do not yet work with m2m-prefetches
levels = Level.objects.filter(pk__in=levels_for_level.levels).filter(Level.q_for_request(request))
# graphnodes_qs = request.changeset.wrap_model('GraphNode').objects.all()
levels = levels.prefetch_related(
Prefetch('spaces', Space.objects.filter(Space.q_for_request(request)).only(
'geometry', 'level', 'outside'
)),
Prefetch('doors', Door.objects.filter(Door.q_for_request(request)).only('geometry', 'level')),
Prefetch('spaces__columns', Column.objects.filter(
Q(access_restriction__isnull=True) | ~Column.q_for_request(request)
).only('geometry', 'space')),
Prefetch('spaces__groups', LocationGroup.objects.only(
'color', 'category', 'priority', 'hierarchy', 'category__priority', 'category__allow_spaces'
)),
Prefetch('buildings', Building.objects.only('geometry', 'level')),
Prefetch('spaces__holes', Hole.objects.only('geometry', 'space')),
Prefetch('spaces__altitudemarkers', AltitudeMarker.objects.only('geometry', 'space')),
Prefetch('spaces__wifi_measurements', WifiMeasurement.objects.only('geometry', 'space')),
Prefetch('spaces__ranging_beacons', RangingBeacon.objects.only('geometry', 'space')),
# Prefetch('spaces__graphnodes', graphnodes_qs)
)
levels = {s.pk: s for s in levels}
level = levels[level.pk]
levels_under = [levels[pk] for pk in levels_for_level.levels_under]
levels_on_top = [levels[pk] for pk in levels_for_level.levels_on_top]
# todo: permissions
# graphnodes = tuple(chain(*(space.graphnodes.all()
# for space in chain(*(level.spaces.all() for level in levels.values())))))
# graphnodes_lookup = {node.pk: node for node in graphnodes}
# graphedges = request.changeset.wrap_model('GraphEdge').objects.all()
# graphedges = graphedges.filter(Q(from_node__in=graphnodes) | Q(to_node__in=graphnodes))
# graphedges = graphedges.select_related('waytype')
# this is faster because we only deserialize graphnode geometries once
# missing_graphnodes = graphnodes_qs.filter(pk__in=set(chain(*((edge.from_node_id, edge.to_node_id)
# for edge in graphedges))))
# graphnodes_lookup.update({node.pk: node for node in missing_graphnodes})
# for edge in graphedges:
# edge._from_node_cache = graphnodes_lookup[edge.from_node_id]
# edge._to_node_cache = graphnodes_lookup[edge.to_node_id]
# graphedges = [edge for edge in graphedges if edge.from_node.space_id != edge.to_node.space_id]
results = chain(
*(_get_geometries_for_one_level(level) for level in levels_under),
_get_geometries_for_one_level(level),
*(_get_geometries_for_one_level(level) for level in levels_on_top),
*(space.altitudemarkers.all() for space in level.spaces.all()),
*(space.wifi_measurements.all() for space in level.spaces.all()),
*(space.ranging_beacons.all() for space in level.spaces.all()),
# graphedges,
# graphnodes,
)
return list(chain(
[('update_cache_key', update_cache_key)],
(conditional_geojson(obj, update_cache_key_match) for obj in results)
))
def get_space_geometries_result(request, space_id: int, update_cache_key: str, update_cache_key_match: bool):
Space = request.changeset.wrap_model('Space')
Area = request.changeset.wrap_model('Area')
POI = request.changeset.wrap_model('POI')
Door = request.changeset.wrap_model('Door')
LocationGroup = request.changeset.wrap_model('LocationGroup')
space_q_for_request = Space.q_for_request(request)
qs = Space.objects.filter(space_q_for_request)
try:
space = qs.select_related('level', 'level__on_top_of').get(pk=space_id)
except Space.DoesNotExist:
raise API404('space not found')
level = space.level
edit_utils = SpaceChildEditUtils(space, request)
if not edit_utils.can_access_child_base_mapdata:
raise APIPermissionDenied
if request.user_permissions.can_access_base_mapdata:
doors = [door for door in level.doors.filter(Door.q_for_request(request)).all()
if door.geometry.wrapped_geom.intersects(space.geometry.wrapped_geom)]
doors_space_geom = unary_union(
[unwrap_geom(door.geometry) for door in doors] +
[unwrap_geom(space.geometry)]
)
levels_for_level = LevelsForLevel.for_level(request, level.primary_level, special_if_on_top=True)
other_spaces = Space.objects.filter(space_q_for_request, level__pk__in=levels_for_level.levels).only(
'geometry', 'level'
).prefetch_related(
Prefetch('groups', LocationGroup.objects.only(
'color', 'category', 'priority', 'hierarchy', 'category__priority', 'category__allow_spaces'
).filter(color__isnull=False))
)
space = next(s for s in other_spaces if s.pk == space.pk)
other_spaces = [s for s in other_spaces
if s.geometry.intersects(doors_space_geom) and s.pk != space.pk]
all_other_spaces = other_spaces
other_spaces_lower = [s for s in other_spaces if s.level_id in levels_for_level.levels_under]
other_spaces_upper = [s for s in other_spaces if s.level_id in levels_for_level.levels_on_top]
other_spaces = [s for s in other_spaces if s.level_id == level.pk]
space.bounds = True
# deactivated for performance reasons
buildings = level.buildings.all()
# buildings_geom = unary_union([building.geometry for building in buildings])
# for other_space in other_spaces:
# if other_space.outside:
# other_space.geometry = other_space.geometry.difference(buildings_geom)
for other_space in chain(other_spaces, other_spaces_lower, other_spaces_upper):
other_space.opacity = 0.4
other_space.color = '#ffffff'
for building in buildings:
building.opacity = 0.5
else:
buildings = []
doors = []
other_spaces = []
other_spaces_lower = []
other_spaces_upper = []
all_other_spaces = []
# todo: permissions
if request.user_permissions.can_access_base_mapdata:
graph_nodes = request.changeset.wrap_model('GraphNode').objects.all()
graph_nodes = graph_nodes.filter((Q(space__in=all_other_spaces)) | Q(space__pk=space.pk))
space_graph_nodes = tuple(node for node in graph_nodes if node.space_id == space.pk)
graph_edges = request.changeset.wrap_model('GraphEdge').objects.all()
space_graphnodes_ids = tuple(node.pk for node in space_graph_nodes)
graph_edges = graph_edges.filter(Q(from_node__pk__in=space_graphnodes_ids) |
Q(to_node__pk__in=space_graphnodes_ids))
graph_edges = graph_edges.select_related('from_node', 'to_node', 'waytype').only(
'from_node__geometry', 'to_node__geometry', 'waytype__color'
)
else:
graph_nodes = []
graph_edges = []
areas = space.areas.filter(Area.q_for_request(request)).only(
'geometry', 'space'
).prefetch_related(
Prefetch('groups', LocationGroup.objects.order_by(
'-category__priority', '-hierarchy', '-priority'
).only(
'color', 'category', 'priority', 'hierarchy', 'category__priority', 'category__allow_areas'
))
)
for area in areas:
area.opacity = 0.5
areas = sorted(areas, key=area_sorting_func)
results = chain(
buildings,
other_spaces_lower,
doors,
other_spaces,
[space],
areas,
space.holes.all().only('geometry', 'space'),
space.stairs.all().only('geometry', 'space'),
space.ramps.all().only('geometry', 'space'),
space.obstacles.all().only('geometry', 'space', 'color'),
space.lineobstacles.all().only('geometry', 'width', 'space', 'color'),
space.columns.all().only('geometry', 'space'),
space.altitudemarkers.all().only('geometry', 'space'),
space.wifi_measurements.all().only('geometry', 'space'),
space.ranging_beacons.all().only('geometry', 'space'),
space.pois.filter(POI.q_for_request(request)).only('geometry', 'space').prefetch_related(
Prefetch('groups', LocationGroup.objects.only(
'color', 'category', 'priority', 'hierarchy', 'category__priority', 'category__allow_pois'
).filter(color__isnull=False))
),
other_spaces_upper,
graph_edges,
graph_nodes
)
return list(chain(
[('update_cache_key', update_cache_key)],
(conditional_geojson(obj, update_cache_key_match) for obj in results)
))

View file

@ -1,9 +1,10 @@
from typing import Annotated, Union, Literal, Optional
from typing import Annotated, Literal, Optional, Union
from ninja import Schema
from pydantic import Field as APIField, PositiveInt
from pydantic import Field as APIField
from pydantic import PositiveInt
from c3nav.api.schema import LineSchema, GeometrySchema
from c3nav.api.schema import GeometrySchema, LineSchema
from c3nav.api.utils import NonEmptyStr
GeometryStylesSchema = Annotated[

View file

@ -289,7 +289,7 @@ def call_api_hybrid_view_for_html(func, request, *args, **kwargs):
raise NoAPIHybridResponse
def etag_func(request, *args, **kwargs):
def editor_etag_func(request, *args, **kwargs):
try:
changeset = request.changeset
except AttributeError:

View file

@ -17,8 +17,8 @@ from django.views.decorators.http import etag
from c3nav.editor.forms import GraphEdgeSettingsForm, GraphEditorActionForm
from c3nav.editor.utils import DefaultEditUtils, LevelChildEditUtils, SpaceChildEditUtils
from c3nav.editor.views.base import (APIHybridError, APIHybridFormTemplateResponse, APIHybridLoginRequiredResponse,
APIHybridMessageRedirectResponse, APIHybridTemplateContextResponse, etag_func,
sidebar_view)
APIHybridMessageRedirectResponse, APIHybridTemplateContextResponse,
editor_etag_func, sidebar_view)
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.utils.user import can_access_editor
@ -40,7 +40,7 @@ def child_model(request, model: typing.Union[str, models.Model], kwargs=None, pa
}
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view(api_hybrid=True)
def main_index(request):
Level = request.changeset.wrap_model('Level')
@ -61,7 +61,7 @@ def main_index(request):
}, fields=('can_create_level', 'child_models'))
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view(api_hybrid=True)
def level_detail(request, pk):
Level = request.changeset.wrap_model('Level')
@ -90,7 +90,7 @@ def level_detail(request, pk):
}, fields=('level', 'can_edit_graph', 'can_create_level', 'child_models', 'levels_on_top'))
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view(api_hybrid=True)
def space_detail(request, level, pk):
Level = request.changeset.wrap_model('Level')
@ -126,7 +126,7 @@ def get_changeset_exceeded(request):
return request.user_permissions.max_changeset_changes <= request.changeset.changed_objects_count
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view(api_hybrid=True)
def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False):
changeset_exceeded = get_changeset_exceeded(request)
@ -412,7 +412,7 @@ def get_visible_spaces_kwargs(model, request):
return kwargs
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view(api_hybrid=True)
def list_objects(request, model=None, level=None, space=None, explicit_edit=False):
resolver_match = getattr(request, 'sub_resolver_match', request.resolver_match)
@ -568,7 +568,7 @@ def connect_nodes(request, active_node, clicked_node, edge_settings_form):
messages.success(request, _('Reverse edge overwritten.') if is_reverse else _('Edge overwritten.'))
@etag(etag_func)
@etag(editor_etag_func)
@sidebar_view
def graph_edit(request, level=None, space=None):
if not request.user_permissions.can_access_base_mapdata:

View file

@ -2,7 +2,7 @@ from dataclasses import fields
from django.core.management.base import BaseCommand
from c3nav.mesh.baseformats import normalize_name, StructType
from c3nav.mesh.baseformats import StructType, normalize_name
from c3nav.mesh.messages import MeshMessage
from c3nav.mesh.utils import indent_c

View file

@ -1,7 +1,8 @@
from typing import Annotated, Optional
from ninja import Field as APIField
from ninja import Router as APIRouter, Schema
from ninja import Router as APIRouter
from ninja import Schema
from pydantic import NegativeInt
from c3nav.api.newauth import auth_responses

View file

@ -1,12 +1,14 @@
from enum import StrEnum
from typing import Annotated, Union, Optional
from typing import Annotated, Optional, Union
from ninja import Router as APIRouter, Schema, Field as APIField
from ninja import Field as APIField
from ninja import Router as APIRouter
from ninja import Schema
from pydantic import PositiveInt
from c3nav.api.newauth import auth_responses, validate_responses
from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.models import Source
from pydantic import PositiveInt
from c3nav.mapdata.schemas.model_base import AnyLocationID, Coordinates3D
from c3nav.mapdata.schemas.responses import BoundsSchema