diff --git a/src/c3nav/api/__init__.py b/src/c3nav/api/__init__.py
index c12a4721..e69de29b 100644
--- a/src/c3nav/api/__init__.py
+++ b/src/c3nav/api/__init__.py
@@ -1,35 +0,0 @@
-from functools import wraps
-
-from rest_framework.generics import GenericAPIView
-from rest_framework.renderers import JSONRenderer
-
-from c3nav.mapdata.utils.json import json_encoder_reindent
-
-
-orig_render = JSONRenderer.render
-
-
-@wraps(JSONRenderer.render)
-def nicer_renderer(self, data, accepted_media_type=None, renderer_context=None):
- if self.get_indent(accepted_media_type, renderer_context) is None:
- return orig_render(self, data, accepted_media_type, renderer_context)
- shorten_limit = 50
- if isinstance(data, (list, tuple)):
- shorten_limit = 5 if any(('geometry' in item) for item in data[:50]) else 50
- shorten = isinstance(data, (list, tuple)) and len(data) > shorten_limit
- if shorten:
- remaining_len = len(data)-shorten_limit
- data = data[:shorten_limit]
- result = json_encoder_reindent(lambda d: orig_render(self, d, accepted_media_type, renderer_context), data)
- if shorten:
- result = (result[:-2] +
- ('\n ...%d more elements (truncated for HTML preview)...' % remaining_len).encode() +
- result[-2:])
- return result
-
-
-# Monkey patch for nicer indentation in the django rest framework
-JSONRenderer.render = nicer_renderer
-
-# Fuck serializers!
-del GenericAPIView.get_serializer
diff --git a/src/c3nav/api/api.py b/src/c3nav/api/api.py
deleted file mode 100644
index cb6c261b..00000000
--- a/src/c3nav/api/api.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from django.contrib.auth import login, logout
-from django.contrib.auth.forms import AuthenticationForm
-from django.middleware import csrf
-from django.utils.translation import gettext_lazy as _
-from rest_framework.authentication import SessionAuthentication
-from rest_framework.decorators import action
-from rest_framework.exceptions import ParseError, PermissionDenied
-from rest_framework.response import Response
-from rest_framework.viewsets import ViewSet
-
-from c3nav.api.models import LoginToken
-from c3nav.api.utils import get_api_post_data
-
-
-class SessionViewSet(ViewSet):
- """
- Session for Login, Logout, etc…
- Don't forget to set X-Csrftoken for POST requests!
-
- /login – POST with fields token or username and password to log in
- /get_token – POST with fields username and password to get a login token
- /logout - POST to log out
- """
- def list(self, request, *args, **kwargs):
- return Response({
- 'is_authenticated': request.user.is_authenticated,
- 'csrf_token': csrf.get_token(request),
- })
-
- @action(detail=False, methods=['post'])
- def login(self, request, *args, **kwargs):
- # django-rest-framework doesn't do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- if request.user.is_authenticated:
- raise ParseError(_('Log out first.'))
-
- data = get_api_post_data(request)
-
- if 'token' in data:
- try:
- token = LoginToken.get_by_token(data['token'])
- except LoginToken.DoesNotExist:
- raise PermissionDenied(_('This token does not exist or is no longer valid.'))
- user = token.user
- elif 'username' in data:
- form = AuthenticationForm(request, data=data)
- if not form.is_valid():
- raise ParseError(form.errors)
- user = form.user_cache
- else:
- raise ParseError(_('You need to send a token or username and password.'))
-
- login(request, user)
-
- return Response({
- 'detail': _('Login successful.'),
- 'csrf_token': csrf.get_token(request),
- })
-
- @action(detail=False, methods=['post'])
- def get_token(self, request, *args, **kwargs):
- # django-rest-framework doesn't do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- data = get_api_post_data(request)
-
- form = AuthenticationForm(request, data=data)
- if not form.is_valid():
- raise ParseError(form.errors)
-
- token = form.user_cache.login_tokens.create()
-
- return Response({
- 'token': token.get_token(),
- })
-
- @action(detail=False, methods=['post'])
- def logout(self, request, *args, **kwargs):
- # django-rest-framework doesn't do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- if not request.user.is_authenticated:
- return ParseError(_('Not logged in.'))
-
- logout(request)
-
- return Response({
- 'detail': _('Logout successful.'),
- 'csrf_token': csrf.get_token(request),
- })
diff --git a/src/c3nav/api/auth.py b/src/c3nav/api/auth.py
deleted file mode 100644
index 95e1e494..00000000
--- a/src/c3nav/api/auth.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from django.utils.translation import gettext_lazy as _
-from rest_framework.authentication import TokenAuthentication
-from rest_framework.exceptions import AuthenticationFailed
-
-
-class APISecretAuthentication(TokenAuthentication):
- def authenticate_credentials(self, key):
- try:
- from c3nav.api.models import Secret
- secret = Secret.objects.filter(api_secret=key).select_related('user', 'user__permissions')
- # todo: auth scopes are ignored here, we need to get rid of this
- except Secret.DoesNotExist:
- raise AuthenticationFailed(_('Invalid token.'))
-
- if not secret.user.is_active:
- raise AuthenticationFailed(_('User inactive or deleted.'))
-
- return (secret.user, secret)
diff --git a/src/c3nav/api/templates/rest_framework/api.html b/src/c3nav/api/templates/rest_framework/api.html
deleted file mode 100644
index 36b273ca..00000000
--- a/src/c3nav/api/templates/rest_framework/api.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% extends "rest_framework/base.html" %}
-{% load static %}
-{% load compress %}
-
-{% block title %}{% if name %}{{ name }} – {% endif %}c3nav API{% endblock %}
-
-{% block bootstrap_navbar_variant %}{% endblock %}
-
-{% block meta %}
-
-
-{% if favicon %}
-
-{% endif %}
-{% endblock %}
-
-{% block style %}
-{% compress css %}
-
-
-
-
-{% endcompress %}
-{% endblock %}
-
-{% block branding %}
-c3nav API
-{% endblock %}
diff --git a/src/c3nav/api/urls.py b/src/c3nav/api/urls.py
index bb91fd9f..b33bc9b4 100644
--- a/src/c3nav/api/urls.py
+++ b/src/c3nav/api/urls.py
@@ -1,30 +1,13 @@
-import inspect
-import re
-from collections import OrderedDict
+from django.urls import path
+from django.views.generic.base import RedirectView
-from django.urls import include, path, re_path
-from django.utils.functional import cached_property
-from rest_framework.generics import GenericAPIView
-from rest_framework.response import Response
-from rest_framework.routers import SimpleRouter
-
-from c3nav.api.api import SessionViewSet
from c3nav.api.newapi import auth_api_router
from c3nav.api.ninja import ninja_api
-from c3nav.editor.api import ChangeSetViewSet, EditorViewSet
from c3nav.editor.newapi.endpoints import editor_api_router
-from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionViewSet, AreaViewSet, BuildingViewSet,
- ColumnViewSet, CrossDescriptionViewSet, DoorViewSet, DynamicLocationPositionViewSet,
- HoleViewSet, LeaveDescriptionViewSet, LevelViewSet, LineObstacleViewSet,
- LocationBySlugViewSet, LocationGroupCategoryViewSet, LocationGroupViewSet,
- LocationViewSet, MapViewSet, ObstacleViewSet, POIViewSet, RampViewSet, SourceViewSet,
- SpaceViewSet, StairViewSet, UpdatesViewSet)
from c3nav.mapdata.newapi.map import map_api_router
from c3nav.mapdata.newapi.mapdata import mapdata_api_router
from c3nav.mapdata.newapi.updates import updates_api_router
-from c3nav.mapdata.utils.user import can_access_editor
from c3nav.mesh.newapi import mesh_api_router
-from c3nav.routing.api import RoutingViewSet
from c3nav.routing.newapi.positioning import positioning_api_router
from c3nav.routing.newapi.routing import routing_api_router
@@ -41,89 +24,8 @@ ninja_api.add_router("/editor/", editor_api_router)
ninja_api.add_router("/mesh/", mesh_api_router)
-"""
-legacy API
-"""
-router = SimpleRouter()
-
-router.register(r'map', MapViewSet, basename='map')
-router.register(r'levels', LevelViewSet)
-router.register(r'buildings', BuildingViewSet)
-router.register(r'spaces', SpaceViewSet)
-router.register(r'doors', DoorViewSet)
-router.register(r'holes', HoleViewSet)
-router.register(r'areas', AreaViewSet)
-router.register(r'stairs', StairViewSet)
-router.register(r'ramps', RampViewSet)
-router.register(r'obstacles', ObstacleViewSet)
-router.register(r'lineobstacles', LineObstacleViewSet)
-router.register(r'columns', ColumnViewSet)
-router.register(r'pois', POIViewSet)
-router.register(r'leavedescriptions', LeaveDescriptionViewSet)
-router.register(r'crossdescriptions', CrossDescriptionViewSet)
-router.register(r'sources', SourceViewSet)
-router.register(r'accessrestrictions', AccessRestrictionViewSet)
-router.register(r'accessrestrictiongroups', AccessRestrictionGroupViewSet)
-
-router.register(r'locations', LocationViewSet)
-router.register(r'locations/by_slug', LocationBySlugViewSet, basename='location-by-slug')
-router.register(r'locations/dynamic', DynamicLocationPositionViewSet, basename='dynamic-location')
-router.register(r'locationgroupcategories', LocationGroupCategoryViewSet)
-router.register(r'locationgroups', LocationGroupViewSet)
-
-router.register(r'updates', UpdatesViewSet, basename='updates')
-
-router.register(r'routing', RoutingViewSet, basename='routing')
-
-router.register(r'editor', EditorViewSet, basename='editor')
-router.register(r'changesets', ChangeSetViewSet)
-router.register(r'session', SessionViewSet, basename='session')
-
-
-
-class APIRoot(GenericAPIView):
- """
- Welcome to the c3nav RESTful API.
- The HTML preview is only shown because your Browser sent text/html in its Accept header.
- If you want to use this API on a large scale, please use a client that supports E-Tags.
- For more information on a specific API endpoint, access it with a browser.
-
- This is the old API which is slowly being phased out in favor of the new API at /api/v2/.
- """
-
- def _format_pattern(self, pattern):
- return re.sub(r'\(\?P<([^>]*[^>_])_?>[^)]+\)', r'{\1}', pattern)[1:-1]
-
- @cached_property
- def urls(self):
- include_editor = can_access_editor(self.request)
- urls: dict[str, dict[str, str] | str] = OrderedDict()
- for urlpattern in router.urls:
- if not include_editor and inspect.getmodule(urlpattern.callback).__name__.startswith('c3nav.editor.'):
- continue
- name = urlpattern.name
- url = self._format_pattern(str(urlpattern.pattern)).replace('{pk}', '{id}')
- base = url.split('/', 1)[0]
- if base == 'editor':
- if name == 'editor-list':
- continue
- if name == 'editor-detail':
- name = 'editor-api'
- elif base == 'session':
- if name == 'session-list':
- name = 'session-info'
- if '-' in name:
- urls.setdefault(base, OrderedDict())[name.split('-', 1)[1]] = url
- else:
- urls[name] = url
- return urls
-
- def get(self, request):
- return Response(self.urls)
-
-
urlpatterns = [
# todo: does this work? can it be better?
- re_path(r'^$', APIRoot.as_view()),
- path('', include(router.urls)),
+ path('v2/', ninja_api.urls),
+ path('', RedirectView.as_view(pattern_name="api-v2:openapi-view")),
]
diff --git a/src/c3nav/editor/api.py b/src/c3nav/editor/api.py
deleted file mode 100644
index f0e1e3eb..00000000
--- a/src/c3nav/editor/api.py
+++ /dev/null
@@ -1,683 +0,0 @@
-from functools import wraps
-from itertools import chain
-
-from django.db.models import Prefetch, Q
-from django.urls import Resolver404, resolve
-from django.utils.functional import cached_property
-from django.utils.translation import gettext_lazy as _
-from rest_framework.authentication import SessionAuthentication
-from rest_framework.decorators import action
-from rest_framework.exceptions import NotFound, ParseError, PermissionDenied, ValidationError
-from rest_framework.generics import get_object_or_404
-from rest_framework.response import Response
-from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
-from shapely import prepared
-from shapely.ops import unary_union
-
-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 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
-from c3nav.mapdata.utils.geometry import unwrap_geom
-from c3nav.mapdata.utils.user import can_access_editor
-
-
-class EditorViewSetMixin(ViewSet):
- def initial(self, request, *args, **kwargs):
- if not can_access_editor(request):
- raise PermissionDenied
- return super().initial(request, *args, **kwargs)
-
-
-def api_etag_with_update_cache_key(**outkwargs):
- outkwargs.setdefault('cache_kwargs', {})['update_cache_key_match'] = bool
-
- def wrapper(func):
- func = api_etag(**outkwargs)(func)
-
- @wraps(func)
- def wrapped_func(self, request, *args, **kwargs):
- try:
- changeset = request.changeset
- except AttributeError:
- changeset = ChangeSet.get_for_request(request)
- request.changeset = changeset
-
- update_cache_key = request.changeset.raw_cache_key_without_changes
- update_cache_key_match = request.GET.get('update_cache_key') == update_cache_key
- return func(self, request, *args,
- update_cache_key=update_cache_key, update_cache_key_match=update_cache_key_match,
- **kwargs)
- return wrapped_func
- return wrapper
-
-
-class EditorViewSet(EditorViewSetMixin, ViewSet):
- """
- Editor API
- /geometries/ returns a list of geojson features, you have to specify ?level= or ?space=
- /geometrystyles/ returns styling information for all geometry types
- /bounds/ returns the maximum bounds of the map
- /{path}/ insert an editor path to get an API represantation of it. POST requests on forms are possible as well
- """
- lookup_field = 'path'
- lookup_value_regex = r'.+'
-
- @staticmethod
- 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)
-
- @staticmethod
- def _get_level_geometries(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=EditorViewSet.space_sorting_func))
- return results
-
- @staticmethod
- def _get_levels_pk(request, level):
- # 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 = chain([level.pk], levels_under, levels_on_top)
- return levels, levels_on_top, levels_under
-
- @staticmethod
- 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)
-
- # noinspection PyPep8Naming
- @action(detail=False, methods=['get'])
- @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')
- 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')
-
- level = request.GET.get('level')
- space = request.GET.get('space')
- if level is not None:
- if space is not None:
- raise ValidationError('Only level or space can be specified.')
-
- level = get_object_or_404(Level.objects.filter(Level.q_for_request(request)), pk=level)
-
- edit_utils = LevelChildEditUtils(level, request)
- if not edit_utils.can_access_child_base_mapdata:
- raise PermissionDenied
-
- levels, levels_on_top, levels_under = self._get_levels_pk(request, level)
- # don't prefetch groups for now as changesets do not yet work with m2m-prefetches
- levels = Level.objects.filter(pk__in=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_under]
- levels_on_top = [levels[pk] for pk in 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(
- *(self._get_level_geometries(level) for level in levels_under),
- self._get_level_geometries(level),
- *(self._get_level_geometries(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,
- )
- elif space is not None:
- space_q_for_request = Space.q_for_request(request)
- qs = Space.objects.filter(space_q_for_request)
- space = get_object_or_404(qs.select_related('level', 'level__on_top_of'), pk=space)
- level = space.level
-
- edit_utils = SpaceChildEditUtils(space, request)
- if not edit_utils.can_access_child_base_mapdata:
- raise PermissionDenied
-
- 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, levels_on_top, levels_under = self._get_levels_pk(request, level.primary_level)
- if level.on_top_of_id is not None:
- levels = chain([level.pk], levels_on_top)
- other_spaces = Space.objects.filter(space_q_for_request, level__pk__in=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
-
- if level.on_top_of_id is None:
- other_spaces_lower = [s for s in other_spaces if s.level_id in levels_under]
- other_spaces_upper = [s for s in other_spaces if s.level_id in levels_on_top]
- else:
- other_spaces_lower = [s for s in other_spaces if s.level_id == level.on_top_of_id]
- other_spaces_upper = []
- 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:
- graphnodes = request.changeset.wrap_model('GraphNode').objects.all()
- graphnodes = graphnodes.filter((Q(space__in=all_other_spaces)) | Q(space__pk=space.pk))
-
- space_graphnodes = tuple(node for node in graphnodes if node.space_id == space.pk)
-
- graphedges = request.changeset.wrap_model('GraphEdge').objects.all()
- space_graphnodes_ids = tuple(node.pk for node in space_graphnodes)
- graphedges = graphedges.filter(Q(from_node__pk__in=space_graphnodes_ids) |
- Q(to_node__pk__in=space_graphnodes_ids))
- graphedges = graphedges.select_related('from_node', 'to_node', 'waytype').only(
- 'from_node__geometry', 'to_node__geometry', 'waytype__color'
- )
- else:
- graphnodes = []
- graphedges = []
-
- 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=self.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,
- graphedges,
- graphnodes
- )
- else:
- raise ValidationError('No level or space specified.')
-
- return Response(list(chain(
- [('update_cache_key', update_cache_key)],
- (self.conditional_geojson(obj, update_cache_key_match) for obj in results)
- )))
-
- def conditional_geojson(self, 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
-
- @action(detail=False, methods=['get'])
- @api_etag(etag_func=MapUpdate.current_cache_key, cache_parameters={})
- def geometrystyles(self, request, *args, **kwargs):
- return Response({
- 'building': '#aaaaaa',
- 'space': '#eeeeee',
- 'hole': 'rgba(255, 0, 0, 0.3)',
- 'door': '#ffffff',
- 'area': '#55aaff',
- 'stair': '#a000a0',
- 'ramp': 'rgba(160, 0, 160, 0.2)',
- 'obstacle': '#999999',
- 'lineobstacle': '#999999',
- 'column': 'rgba(0, 0, 50, 0.3)',
- 'poi': '#4488cc',
- 'shadow': '#000000',
- 'graphnode': '#009900',
- 'graphedge': '#00CC00',
- 'altitudemarker': '#0000FF',
- 'wifimeasurement': '#DDDD00',
- 'rangingbeacon': '#CC00CC',
- })
-
- @action(detail=False, methods=['get'])
- @api_etag(etag_func=editor_etag_func, cache_parameters={})
- def bounds(self, request, *args, **kwargs):
- return Response({
- 'bounds': Source.max_bounds(),
- })
-
- def __getattr__(self, name):
- # allow POST and DELETE methods for the editor API
-
- if getattr(self, 'get', None).__name__ in ('list', 'retrieve'):
- if name == 'post' and (self.resolved.url_name.endswith('.create') or
- self.resolved.url_name.endswith('.edit')):
- return self.post_or_delete
- if name == 'delete' and self.resolved.url_name.endswith('.edit'):
- return self.post_or_delete
- raise AttributeError
-
- def post_or_delete(self, request, *args, **kwargs):
- # django-rest-framework doesn't automatically do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- return self.retrieve(request, *args, **kwargs)
-
- def list(self, request, *args, **kwargs):
- return self.retrieve(request, *args, **kwargs)
-
- @cached_property
- def resolved(self):
- resolved = None
- path = self.kwargs.get('path', '')
- if path:
- try:
- resolved = resolve('/editor/'+path+'/')
- except Resolver404:
- pass
-
- if not resolved:
- try:
- resolved = resolve('/editor/'+path)
- except Resolver404:
- pass
-
- self.request.sub_resolver_match = resolved
-
- return resolved
-
- def retrieve(self, request, *args, **kwargs):
- resolved = self.resolved
- if not resolved:
- raise NotFound(_('No matching editor view endpoint found.'))
-
- if not getattr(resolved.func, 'api_hybrid', False):
- raise NotFound(_('Matching editor view point does not provide an API.'))
-
- get_api_post_data(request)
-
- response = resolved.func(request, api=True, *resolved.args, **resolved.kwargs)
- return response
-
-
-class ChangeSetViewSet(EditorViewSetMixin, ReadOnlyModelViewSet):
- """
- List and manipulate changesets. All lists are ordered by last update descending. Use ?offset= to specify an offset.
- Don't forget to set X-Csrftoken for POST requests!
-
- / lists all changesets this user can see.
- /user/ lists changesets by this user
- /reviewing/ lists changesets this user is currently reviewing.
- /pending_review/ lists changesets this user can review.
-
- /current/ returns the current changeset.
- /direct_editing/ POST to activate direct editing (if available).
- /deactive/ POST to deactivate current changeset or deactivate direct editing
-
- /{id}/changes/ list all changes of a given changeset.
- /{id}/activate/ POST to activate given changeset.
- /{id}/edit/ POST to edit given changeset (provide title and description in POST data).
- /{id}/restore_object/ POST to restore an object deleted by this changeset (provide change id as id in POST data).
- /{id}/delete/ POST to delete given changeset.
- /{id}/propose/ POST to propose given changeset.
- /{id}/unpropose/ POST to unpropose given changeset.
- /{id}/review/ POST to review given changeset.
- /{id}/reject/ POST to reject given changeset (provide reject=1 in POST data for final rejection).
- /{id}/unreject/ POST to unreject given changeset.
- /{id}/apply/ POST to accept and apply given changeset.
- """
- queryset = ChangeSet.objects.all()
-
- def get_queryset(self):
- return ChangeSet.qs_for_request(self.request).select_related('last_update', 'last_state_update', 'last_change')
-
- def _list(self, request, qs):
- offset = 0
- if 'offset' in request.GET:
- if not request.GET['offset'].isdigit():
- raise ParseError('offset has to be a positive integer.')
- offset = int(request.GET['offset'])
- return Response([obj.serialize() for obj in qs.order_by('-last_update')[offset:offset+20]])
-
- def list(self, request, *args, **kwargs):
- return self._list(request, self.get_queryset())
-
- @action(detail=False, methods=['get'])
- def user(self, request, *args, **kwargs):
- return self._list(request, self.get_queryset().filter(author=request.user))
-
- @action(detail=False, methods=['get'])
- def reviewing(self, request, *args, **kwargs):
- return self._list(request, self.get_queryset().filter(
- assigned_to=request.user, state='review'
- ))
-
- @action(detail=False, methods=['get'])
- def pending_review(self, request, *args, **kwargs):
- return self._list(request, self.get_queryset().filter(
- state__in=('proposed', 'reproposed'),
- ))
-
- def retrieve(self, request, *args, **kwargs):
- return Response(self.get_object().serialize())
-
- @action(detail=False, methods=['get'])
- def current(self, request, *args, **kwargs):
- changeset = ChangeSet.get_for_request(request)
- return Response({
- 'direct_editing': changeset.direct_editing,
- 'changeset': changeset.serialize() if changeset.pk else None,
- })
-
- @action(detail=False, methods=['post'])
- def direct_editing(self, request, *args, **kwargs):
- # django-rest-framework doesn't automatically do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- if not ChangeSet.can_direct_edit(request):
- raise PermissionDenied(_('You don\'t have the permission to activate direct editing.'))
-
- changeset = ChangeSet.get_for_request(request)
- if changeset.pk is not None:
- raise PermissionDenied(_('You cannot activate direct editing if you have an active changeset.'))
-
- request.session['direct_editing'] = True
-
- return Response({
- 'success': True,
- })
-
- @action(detail=False, methods=['post'])
- def deactivate(self, request, *args, **kwargs):
- # django-rest-framework doesn't automatically do this for logged out requests
- SessionAuthentication().enforce_csrf(request)
-
- request.session.pop('changeset', None)
- request.session['direct_editing'] = False
-
- return Response({
- 'success': True,
- })
-
- @action(detail=True, methods=['get'])
- def changes(self, request, *args, **kwargs):
- changeset = self.get_object()
- changeset.fill_changes_cache()
- return Response([obj.serialize() for obj in changeset.iter_changed_objects()])
-
- @action(detail=True, methods=['post'])
- def activate(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_activate(request):
- raise PermissionDenied(_('You can not activate this change set.'))
-
- changeset.activate(request)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def edit(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_edit(request):
- raise PermissionDenied(_('You cannot edit this change set.'))
-
- form = ChangeSetForm(instance=changeset, data=get_api_post_data(request))
- if not form.is_valid():
- raise ParseError(form.errors)
-
- changeset = form.instance
- update = changeset.updates.create(user=request.user,
- title=changeset.title, description=changeset.description)
- changeset.last_update = update
- changeset.save()
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def restore_object(self, request, *args, **kwargs):
- data = get_api_post_data(request)
- if 'id' not in data:
- raise ParseError('Missing id.')
-
- restore_id = data['id']
- if isinstance(restore_id, str) and restore_id.isdigit():
- restore_id = int(restore_id)
-
- if not isinstance(restore_id, int):
- raise ParseError('id needs to be an integer.')
-
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_edit(request):
- raise PermissionDenied(_('You can not edit changes on this change set.'))
-
- try:
- changed_object = changeset.changed_objects_set.get(pk=restore_id)
- except Exception:
- raise NotFound('could not find object.')
-
- try:
- changed_object.restore()
- except PermissionError:
- raise PermissionDenied(_('You cannot restore this object, because it depends on '
- 'a deleted object or it would violate a unique contraint.'))
-
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def propose(self, request, *args, **kwargs):
- if not request.user.is_authenticated:
- raise PermissionDenied(_('You need to log in to propose changes.'))
-
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.title or not changeset.description:
- raise PermissionDenied(_('You need to add a title an a description to propose this change set.'))
-
- if not changeset.can_propose(request):
- raise PermissionDenied(_('You cannot propose this change set.'))
-
- changeset.propose(request.user)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def unpropose(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_unpropose(request):
- raise PermissionDenied(_('You cannot unpropose this change set.'))
-
- changeset.unpropose(request.user)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def review(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_start_review(request):
- raise PermissionDenied(_('You cannot review these changes.'))
-
- changeset.start_review(request.user)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def reject(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not not changeset.can_end_review(request):
- raise PermissionDenied(_('You cannot reject these changes.'))
-
- form = RejectForm(get_api_post_data(request))
- if not form.is_valid():
- raise ParseError(form.errors)
-
- changeset.reject(request.user, form.cleaned_data['comment'], form.cleaned_data['final'])
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def unreject(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_unreject(request):
- raise PermissionDenied(_('You cannot unreject these changes.'))
-
- changeset.unreject(request.user)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def apply(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_end_review(request):
- raise PermissionDenied(_('You cannot accept and apply these changes.'))
-
- changeset.apply(request.user)
- return Response({'success': True})
-
- @action(detail=True, methods=['post'])
- def delete(self, request, *args, **kwargs):
- changeset = self.get_object()
- with changeset.lock_to_edit(request) as changeset:
- if not changeset.can_delete(request):
- raise PermissionDenied(_('You cannot delete this change set.'))
-
- changeset.delete()
- return Response({'success': True})
diff --git a/src/c3nav/editor/views/base.py b/src/c3nav/editor/views/base.py
index 3ce7ff64..99844f26 100644
--- a/src/c3nav/editor/views/base.py
+++ b/src/c3nav/editor/views/base.py
@@ -14,7 +14,6 @@ from django.shortcuts import redirect, render
from django.utils.cache import patch_vary_headers
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
-from rest_framework.response import Response as APIResponse
from c3nav.editor.models import ChangeSet
from c3nav.editor.wrappers import QuerySetWrapper
@@ -269,7 +268,8 @@ def call_api_hybrid_view_for_api(func, request, *args, **kwargs):
result['messages'] = messages
result.move_to_end('messages', last=False)
- api_response = APIResponse(result, status=response.status_code)
+ # todo: fix this
+ # api_response = APIResponse(result, status=response.status_code)
if request.method == 'GET':
response.add_headers(api_response)
return api_response
diff --git a/src/c3nav/mapdata/api.py b/src/c3nav/mapdata/api.py
deleted file mode 100644
index 0a24858d..00000000
--- a/src/c3nav/mapdata/api.py
+++ /dev/null
@@ -1,550 +0,0 @@
-import mimetypes
-import os
-from collections import namedtuple
-from functools import wraps
-from urllib.parse import urlparse
-
-from django.core.cache import cache
-from django.db.models import Prefetch
-from django.http import Http404, HttpResponse
-from django.shortcuts import redirect
-from django.utils.cache import get_conditional_response
-from django.utils.http import http_date, quote_etag, urlsafe_base64_encode
-from django.utils.translation import get_language
-from django.utils.translation import gettext_lazy as _
-from rest_framework.decorators import action
-from rest_framework.exceptions import NotFound, ValidationError
-from rest_framework.generics import get_object_or_404
-from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
-from rest_framework.response import Response
-from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet, ViewSet
-
-from c3nav.mapdata.models import AccessRestriction, Building, Door, Hole, LocationGroup, MapUpdate, Source, Space
-from c3nav.mapdata.models.access import AccessPermission, AccessRestrictionGroup
-from c3nav.mapdata.models.geometry.base import GeometryMixin
-from c3nav.mapdata.models.geometry.level import LevelGeometryMixin
-from c3nav.mapdata.models.geometry.space import (POI, Area, Column, CrossDescription, LeaveDescription, LineObstacle,
- Obstacle, Ramp, SpaceGeometryMixin, Stair)
-from c3nav.mapdata.models.level import Level
-from c3nav.mapdata.models.locations import (DynamicLocation, Location, LocationGroupCategory, LocationRedirect,
- LocationSlug, Position, SpecificLocation)
-from c3nav.mapdata.utils.cache.local import LocalCacheProxy
-from c3nav.mapdata.utils.cache.stats import increment_cache_key
-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)
-from c3nav.mapdata.utils.models import get_submodels
-from c3nav.mapdata.utils.user import can_access_editor, get_user_data
-from c3nav.mapdata.views import set_tile_access_cookie
-
-request_cache = LocalCacheProxy(maxsize=64)
-
-
-def optimize_query(qs):
- if issubclass(qs.model, SpecificLocation):
- base_qs = LocationGroup.objects.select_related('category')
- qs = qs.prefetch_related(Prefetch('groups', queryset=base_qs))
- if issubclass(qs.model, AccessRestriction):
- qs = qs.prefetch_related('groups')
- return qs
-
-
-def api_stats_clean_location_value(value):
- if isinstance(value, str) and value.startswith('c:'):
- value = value.split(':')
- value = 'c:%s:%d:%d' % (value[1], int(float(value[2]) / 3) * 3, int(float(value[3]) / 3) * 3)
- return (value, 'c:anywhere')
- return (value, )
-
-
-def api_stats(view_name):
- def wrapper(func):
- @wraps(func)
- def wrapped_func(self, request, *args, **kwargs):
- response = func(self, request, *args, **kwargs)
- if response.status_code < 400 and kwargs:
- name, value = next(iter(kwargs.items()))
- for value in api_stats_clean_location_value(value):
- increment_cache_key('apistats__%s__%s__%s' % (view_name, name, value))
- return response
- return wrapped_func
- return wrapper
-
-
-def api_etag(permissions=True, etag_func=AccessPermission.etag_func,
- cache_parameters=None, cache_kwargs=None, base_mapdata_check=False):
- def wrapper(func):
- @wraps(func)
- def wrapped_func(self, request, *args, **kwargs):
- response_format = self.perform_content_negotiation(request)[0].format
- etag_user = (':'+str(request.user.pk or 0)) if response_format == 'api' else ''
- raw_etag = '%s%s:%s:%s' % (response_format, etag_user, get_language(),
- (etag_func(request) if permissions else MapUpdate.current_cache_key()))
- if base_mapdata_check and self.base_mapdata:
- raw_etag += ':%d' % request.user_permissions.can_access_base_mapdata
- etag = quote_etag(raw_etag)
-
- response = get_conditional_response(request, etag=etag)
- if response is None:
- cache_key = 'mapdata:api:'+request.path_info[5:].replace('/', '-').strip('-')+':'+raw_etag
- if cache_parameters is not None:
- for param, type_ in cache_parameters.items():
- value = int(param in request.GET) if type_ == bool else type_(request.GET.get(param))
- cache_key += ':'+urlsafe_base64_encode(str(value).encode())
- if cache_kwargs is not None:
- for name, type_ in cache_kwargs.items():
- value = type_(kwargs[name])
- if type_ == bool:
- value = int(value)
- cache_key += ':'+urlsafe_base64_encode(str(value).encode())
- data = request_cache.get(cache_key)
- if data is not None:
- response = Response(data)
-
- if response is None:
- with GeometryMixin.dont_keep_originals():
- response = func(self, request, *args, **kwargs)
- if cache_parameters is not None and response.status_code == 200:
- request_cache.set(cache_key, response.data, 900)
-
- if response.status_code == 200:
- response['ETag'] = etag
- response['Cache-Control'] = 'no-cache'
- return response
- return wrapped_func
- return wrapper
-
-
-class MapViewSet(ViewSet):
- """
- Map API
- /bounds/ returns the maximum bounds of the map
- """
-
- @action(detail=False, methods=['get'])
- @api_etag(permissions=False, cache_parameters={})
- def bounds(self, request, *args, **kwargs):
- return Response({
- 'bounds': Source.max_bounds(),
- })
-
-
-class MapdataViewSet(ReadOnlyModelViewSet):
- base_mapdata = False
- order_by = ('id', )
-
- def get_queryset(self):
- qs = super().get_queryset()
- if hasattr(qs.model, 'qs_for_request'):
- return qs.model.qs_for_request(self.request)
- return qs
-
- @staticmethod
- def can_access_geometry(request, obj):
- if isinstance(obj, Space):
- return obj.base_mapdata_accessible or request.user_permissions.can_access_base_mapdata
- elif isinstance(obj, (Building, Door)):
- return request.user_permissions.can_access_base_mapdata
- return True
-
- qs_filter = namedtuple('qs_filter', ('field', 'model', 'key', 'value'))
-
- def _get_keys_for_model(self, request, model, key):
- if hasattr(model, 'qs_for_request'):
- cache_key = 'mapdata:api:keys:%s:%s:%s' % (model.__name__, key,
- AccessPermission.cache_key_for_request(request))
- qs = model.qs_for_request(request)
- else:
- cache_key = 'mapdata:api:keys:%s:%s:%s' % (model.__name__, key,
- MapUpdate.current_cache_key())
- qs = model.objects.all()
-
- result = cache.get(cache_key, None)
- if result is not None:
- return result
-
- result = set(qs.values_list(key, flat=True))
- cache.set(cache_key, result, 300)
-
- return result
-
- def _get_list(self, request):
- qs = optimize_query(self.get_queryset())
- filters = []
- if issubclass(qs.model, LevelGeometryMixin) and 'level' in request.GET:
- filters.append(self.qs_filter(field='level', model=Level, key='pk', value=request.GET['level']))
-
- if issubclass(qs.model, SpaceGeometryMixin) and 'space' in request.GET:
- filters.append(self.qs_filter(field='space', model=Space, key='pk', value=request.GET['space']))
-
- if issubclass(qs.model, LocationGroup) and 'category' in request.GET:
- filters.append(self.qs_filter(field='category', model=LocationGroupCategory,
- key='pk' if request.GET['category'].isdigit() else 'name',
- value=request.GET['category']))
-
- if issubclass(qs.model, SpecificLocation) and 'group' in request.GET:
- filters.append(self.qs_filter(field='groups', model=LocationGroup, key='pk', value=request.GET['group']))
-
- if qs.model == Level and 'on_top_of' in request.GET:
- value = None if request.GET['on_top_of'] == 'null' else request.GET['on_top_of']
- filters.append(self.qs_filter(field='on_top_of', model=Level, key='pk', value=value))
-
- cache_key = 'mapdata:api:%s:%s' % (qs.model.__name__, AccessPermission.cache_key_for_request(request))
- for qs_filter in filters:
- cache_key += ';%s,%s' % (qs_filter.field, qs_filter.value)
-
- results = cache.get(cache_key, None)
- if results is not None:
- return results
-
- for qs_filter in filters:
- if qs_filter.key == 'pk' and not qs_filter.value.isdigit():
- raise ValidationError(detail={
- 'detail': _('%(field)s is not an integer.') % {'field': qs_filter.field}
- })
-
- for qs_filter in filters:
- if qs_filter.value is not None:
- keys = self._get_keys_for_model(request, qs_filter.model, qs_filter.key)
- value = int(qs_filter.value) if qs_filter.key == 'pk' else qs_filter.value
- if value not in keys:
- raise NotFound(detail=_('%(model)s not found.') % {'model': qs_filter.model._meta.verbose_name})
-
- results = tuple(qs.order_by(*self.order_by))
- cache.set(cache_key, results, 300)
- return results
-
- @api_etag(base_mapdata_check=True)
- def list(self, request, *args, **kwargs):
- geometry = 'geometry' in request.GET
- results = self._get_list(request)
- if results:
- geometry = geometry and self.can_access_geometry(request, results[0])
-
- return Response([obj.serialize(geometry=geometry) for obj in results])
-
- @api_etag(base_mapdata_check=True)
- def retrieve(self, request, *args, **kwargs):
- obj = self.get_object()
- return Response(obj.serialize(geometry=self.can_access_geometry(request, obj)))
-
- @staticmethod
- def list_types(models_list, **kwargs):
- return Response([
- model.serialize_type(**kwargs) for model in models_list
- ])
-
-
-class LevelViewSet(MapdataViewSet):
- """
- Add ?on_top_of= to filter by on_top_of, add ?group= to filter by group.
- A Level is a Location – so if it is visible, you can use its ID in the Location API as well.
- """
- queryset = Level.objects.all()
-
- @action(detail=False, methods=['get'])
- @api_etag(permissions=False, cache_parameters={})
- def geometrytypes(self, request):
- return self.list_types(get_submodels(LevelGeometryMixin))
-
-
-class BuildingViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries if available, add ?level= to filter by level. """
- queryset = Building.objects.all()
- base_mapdata = True
-
-
-class SpaceViewSet(MapdataViewSet):
- """
- Add ?geometry=1 to get geometries if available, ?level= to filter by level, ?group= to filter by group.
- A Space is a Location – so if it is visible, you can use its ID in the Location API as well.
- """
- queryset = Space.objects.all()
- base_mapdata = True
-
- @action(detail=False, methods=['get'])
- @api_etag(permissions=False, cache_parameters={})
- def geometrytypes(self, request):
- return self.list_types(get_submodels(SpaceGeometryMixin))
-
-
-class DoorViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries if available, add ?level= to filter by level. """
- queryset = Door.objects.all()
- base_mapdata = True
-
-
-class HoleViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = Hole.objects.all()
-
-
-class AreaViewSet(MapdataViewSet):
- """
- Add ?geometry=1 to get geometries, add ?space= to filter by space, add ?group= to filter by group.
- An Area is a Location – so if it is visible, you can use its ID in the Location API as well.
- """
- queryset = Area.objects.all()
-
-
-class StairViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = Stair.objects.all()
-
-
-class RampViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = Ramp.objects.all()
-
-
-class ObstacleViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = Obstacle.objects.all()
-
-
-class LineObstacleViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = LineObstacle.objects.all()
-
-
-class ColumnViewSet(MapdataViewSet):
- """ Add ?geometry=1 to get geometries, add ?space= to filter by space. """
- queryset = Column.objects.all()
-
-
-class POIViewSet(MapdataViewSet):
- """
- Add ?geometry=1 to get geometries, add ?space= to filter by space, add ?group= to filter by group.
- A POI is a Location – so if it is visible, you can use its ID in the Location API as well.
- """
- queryset = POI.objects.all()
-
-
-class LeaveDescriptionViewSet(MapdataViewSet):
- queryset = LeaveDescription.objects.all()
-
-
-class CrossDescriptionViewSet(MapdataViewSet):
- queryset = CrossDescription.objects.all()
-
-
-class LocationGroupCategoryViewSet(MapdataViewSet):
- queryset = LocationGroupCategory.objects.all()
-
-
-class LocationGroupViewSet(MapdataViewSet):
- """
- Add ?category= to filter by category.
- A Location Group is a Location – so if it is visible, you can use its ID in the Location API as well.
- """
- queryset = LocationGroup.objects.all()
-
-
-class LocationViewSetBase(RetrieveModelMixin, GenericViewSet):
- queryset = LocationSlug.objects.all()
- base_mapdata = True
-
- def get_object(self) -> LocationSlug:
- raise NotImplementedError
-
- @api_stats('location_retrieve')
- @api_etag(cache_parameters={'show_redirects': bool, 'detailed': bool, 'geometry': bool}, base_mapdata_check=True)
- def retrieve(self, request, key=None, *args, **kwargs):
- show_redirects = 'show_redirects' in request.GET
- detailed = 'detailed' in request.GET
- geometry = 'geometry' in request.GET
-
- location = self.get_object()
-
- if location is None:
- raise NotFound
-
- if isinstance(location, LocationRedirect):
- if not show_redirects:
- return redirect('../' + str(location.target.slug)) # todo: why does redirect/reverse not work here?
-
- return Response(location.serialize(include_type=True, detailed=detailed,
- geometry=geometry and MapdataViewSet.can_access_geometry(request, location),
- simple_geometry=True))
-
- @action(detail=True, methods=['get'])
- @api_stats('location_details')
- @api_etag(base_mapdata_check=True)
- def details(self, request, **kwargs):
- location = self.get_object()
-
- if location is None:
- raise NotFound
-
- if isinstance(location, LocationRedirect):
- return redirect('../' + str(location.target.pk) + '/details/')
-
- return Response(location.details_display(
- detailed_geometry=MapdataViewSet.can_access_geometry(request, location),
- editor_url=can_access_editor(request)
- ))
-
- @action(detail=True, methods=['get'])
- @api_stats('location_geometry')
- @api_etag(base_mapdata_check=True)
- def geometry(self, request, **kwargs):
- location = self.get_object()
-
- if location is None:
- raise NotFound
-
- if isinstance(location, LocationRedirect):
- return redirect('../' + str(location.target.pk) + '/geometry/')
-
- return Response({
- 'id': location.pk,
- 'level': getattr(location, 'level_id', None),
- 'geometry': location.get_geometry(
- detailed_geometry=MapdataViewSet.can_access_geometry(request, location)
- )
- })
-
-
-class LocationViewSet(LocationViewSetBase):
- """
- Locations are Levels, Spaces, Areas, POIs and Location Groups (see /locations/types/). They have a shared ID pool.
- This API endpoint only accesses locations that have can_search or can_describe set to true.
- If you want to access all of them, use the API endpoints for the Location Types.
- Additionally, you can access Custom Locations (Coordinates) by using c::x:y as an id or slug.
-
- add ?searchable to only show locations with can_search set to true ordered by relevance
- add ?detailed to show all attributes
- add ?geometry to show geometries if available
- /{id}/ add ?show_redirect=1 to suppress redirects and show them as JSON.
- also possible: /by_slug/{slug}/
- """
- queryset = LocationSlug.objects.all()
- lookup_value_regex = r'[^/]+'
-
- def get_object(self):
- return get_location_by_id_for_request(self.kwargs['pk'], self.request)
-
- @api_etag(cache_parameters={'searchable': bool, 'detailed': bool, 'geometry': bool}, base_mapdata_check=True)
- def list(self, request, *args, **kwargs):
- searchable = 'searchable' in request.GET
- detailed = 'detailed' in request.GET
- geometry = 'geometry' in request.GET
-
- cache_key = 'mapdata:api:location:list:%d:%s:%d' % (
- searchable + detailed*2 + geometry*4,
- AccessPermission.cache_key_for_request(request),
- request.user_permissions.can_access_base_mapdata
- )
- result = cache.get(cache_key, None)
- if result is None:
- if searchable:
- locations = searchable_locations_for_request(request)
- else:
- locations = visible_locations_for_request(request).values()
-
- result = tuple(obj.serialize(include_type=True, detailed=detailed, search=searchable,
- geometry=geometry and MapdataViewSet.can_access_geometry(request, obj),
- simple_geometry=True)
- for obj in locations)
- cache.set(cache_key, result, 300)
-
- return Response(result)
-
- @action(detail=False, methods=['get'])
- @api_etag(permissions=False)
- def types(self, request):
- return MapdataViewSet.list_types(get_submodels(Location), geomtype=False)
-
-
-class LocationBySlugViewSet(LocationViewSetBase):
- queryset = LocationSlug.objects.all()
- lookup_field = 'slug'
- lookup_value_regex = r'[^/]+'
-
- def get_object(self):
- return get_location_by_slug_for_request(self.kwargs['slug'], self.request)
-
-
-class DynamicLocationPositionViewSet(UpdateModelMixin, RetrieveModelMixin, GenericViewSet):
- queryset = LocationSlug.objects.all()
- lookup_field = 'slug'
- lookup_value_regex = r'[^/]+'
-
- def get_object(self):
- slug = self.kwargs['slug']
- if slug.startswith('p:'):
- return get_object_or_404(Position, secret=slug[2:])
- if slug.isdigit():
- location = get_location_by_id_for_request(slug, self.request)
- if isinstance(location, DynamicLocation):
- return location
- raise Http404
-
- @api_stats('dynamic_location_retrieve')
- def retrieve(self, request, key=None, *args, **kwargs):
- obj = self.get_object()
- return Response(obj.serialize_position())
-
-
-class SourceViewSet(MapdataViewSet):
- queryset = Source.objects.all()
- order_by = ('name',)
-
- @action(detail=True, methods=['get'])
- def image(self, request, pk=None):
- return self._image(request, pk=pk)
-
- def _image(self, request, pk=None):
- source = self.get_object()
- last_modified = int(os.path.getmtime(source.filepath))
- response = get_conditional_response(request, last_modified=last_modified)
- if response is None:
- response = HttpResponse(open(source.filepath, 'rb'), content_type=mimetypes.guess_type(source.name)[0])
- response['Last-Modified'] = http_date(last_modified)
- return response
-
-
-class AccessRestrictionViewSet(MapdataViewSet):
- queryset = AccessRestriction.objects.all()
-
-
-class AccessRestrictionGroupViewSet(MapdataViewSet):
- queryset = AccessRestrictionGroup.objects.all()
-
-
-class UpdatesViewSet(GenericViewSet):
- """
- Get information about recent updates.
- Get display information about the current user.
- Set the tile access cookie.
- The tile access cookie is only valid for 1 minute, so if you are displaying a map, call this endpoint repeatedly.
- """
- @action(detail=False, methods=['get'])
- def fetch(self, request, key=None):
- cross_origin = request.META.get('HTTP_ORIGIN')
- if cross_origin is not None:
- try:
- if request.META['HTTP_HOST'] == urlparse(cross_origin).hostname:
- cross_origin = None
- except ValueError:
- pass
-
- increment_cache_key('api_updates_fetch_requests%s' % ('_cross_origin' if cross_origin is not None else ''))
-
- from c3nav.site.models import SiteUpdate
-
- result = {
- 'last_site_update': SiteUpdate.last_update(),
- 'last_map_update': MapUpdate.current_processed_cache_key(),
- }
- if cross_origin is None:
- result.update({
- 'user': get_user_data(request),
- })
-
- response = Response(result)
- if cross_origin is not None:
- response['Access-Control-Allow-Origin'] = cross_origin
- response['Access-Control-Allow-Credentials'] = 'true'
- set_tile_access_cookie(request, response)
-
- return response
diff --git a/src/c3nav/mapdata/newapi/base.py b/src/c3nav/mapdata/newapi/base.py
index 47fcb0fa..6674d7da 100644
--- a/src/c3nav/mapdata/newapi/base.py
+++ b/src/c3nav/mapdata/newapi/base.py
@@ -2,15 +2,16 @@ import json
from functools import wraps
from django.core.serializers.json import DjangoJSONEncoder
+from django.db.models import Prefetch
from django.utils.cache import get_conditional_response
from django.utils.http import quote_etag
from django.utils.translation import get_language
from ninja.decorators import decorate_view
-from c3nav.mapdata.api import api_stats_clean_location_value
-from c3nav.mapdata.models import MapUpdate
+from c3nav.mapdata.models import AccessRestriction, LocationGroup, MapUpdate
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.models.geometry.base import GeometryMixin
+from c3nav.mapdata.models.locations import SpecificLocation
from c3nav.mapdata.utils.cache.local import LocalCacheProxy
from c3nav.mapdata.utils.cache.stats import increment_cache_key
@@ -90,3 +91,34 @@ def newapi_stats(stat_name):
return response
return wrapped_func
return decorate_view(wrapper)
+
+
+def optimize_query(qs):
+ if issubclass(qs.model, SpecificLocation):
+ base_qs = LocationGroup.objects.select_related('category')
+ qs = qs.prefetch_related(Prefetch('groups', queryset=base_qs))
+ if issubclass(qs.model, AccessRestriction):
+ qs = qs.prefetch_related('groups')
+ return qs
+
+
+def api_stats_clean_location_value(value):
+ if isinstance(value, str) and value.startswith('c:'):
+ value = value.split(':')
+ value = 'c:%s:%d:%d' % (value[1], int(float(value[2]) / 3) * 3, int(float(value[3]) / 3) * 3)
+ return (value, 'c:anywhere')
+ return (value, )
+
+
+def api_stats(view_name):
+ def wrapper(func):
+ @wraps(func)
+ def wrapped_func(self, request, *args, **kwargs):
+ response = func(self, request, *args, **kwargs)
+ if response.status_code < 400 and kwargs:
+ name, value = next(iter(kwargs.items()))
+ for value in api_stats_clean_location_value(value):
+ increment_cache_key('apistats__%s__%s__%s' % (view_name, name, value))
+ return response
+ return wrapped_func
+ return wrapper
diff --git a/src/c3nav/mapdata/newapi/map.py b/src/c3nav/mapdata/newapi/map.py
index b64f0a5c..c8411610 100644
--- a/src/c3nav/mapdata/newapi/map.py
+++ b/src/c3nav/mapdata/newapi/map.py
@@ -60,10 +60,10 @@ def _location_list(request, detailed: bool, filters: LocationListFilters):
else:
locations = visible_locations_for_request(request).values()
- result = tuple(obj.serialize(detailed=detailed, search=filters.searchable,
- geometry=filters.geometry and can_access_geometry(request),
- simple_geometry=True)
- for obj in locations)
+ result = [obj.serialize(detailed=detailed, search=filters.searchable,
+ geometry=filters.geometry and can_access_geometry(request),
+ simple_geometry=True)
+ for obj in locations]
return result
diff --git a/src/c3nav/mapdata/newapi/mapdata.py b/src/c3nav/mapdata/newapi/mapdata.py
index 8aa26434..7ffe0afc 100644
--- a/src/c3nav/mapdata/newapi/mapdata.py
+++ b/src/c3nav/mapdata/newapi/mapdata.py
@@ -6,14 +6,13 @@ from ninja import Router as APIRouter
from c3nav.api.exceptions import API404
from c3nav.api.newauth import auth_responses, validate_responses
-from c3nav.mapdata.api import optimize_query
from c3nav.mapdata.models import (Area, Building, Door, Hole, Level, LocationGroup, LocationGroupCategory, Source,
Space, Stair)
from c3nav.mapdata.models.access import AccessRestriction, AccessRestrictionGroup
from c3nav.mapdata.models.geometry.space import (POI, Column, CrossDescription, LeaveDescription, LineObstacle,
Obstacle, Ramp)
from c3nav.mapdata.models.locations import DynamicLocation
-from c3nav.mapdata.newapi.base import newapi_etag
+from c3nav.mapdata.newapi.base import newapi_etag, optimize_query
from c3nav.mapdata.schemas.filters import (ByCategoryFilter, ByGroupFilter, ByOnTopOfFilter, FilterSchema,
LevelGeometryFilter, SpaceGeometryFilter)
from c3nav.mapdata.schemas.models import (AccessRestrictionGroupSchema, AccessRestrictionSchema, AreaSchema,
diff --git a/src/c3nav/routing/api.py b/src/c3nav/routing/api.py
deleted file mode 100644
index abdcf488..00000000
--- a/src/c3nav/routing/api.py
+++ /dev/null
@@ -1,200 +0,0 @@
-from django.core.exceptions import ValidationError
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-from rest_framework.decorators import action
-from rest_framework.response import Response
-from rest_framework.viewsets import ViewSet
-
-from c3nav.mapdata.api import api_stats_clean_location_value
-from c3nav.mapdata.models.access import AccessPermission
-from c3nav.mapdata.models.locations import Position
-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.locator import Locator
-from c3nav.routing.models import RouteOptions
-from c3nav.routing.rangelocator import RangeLocator
-from c3nav.routing.router import Router
-
-
-class RoutingViewSet(ViewSet):
- """
- /route/ Get routes.
- /options/ Get or set route options.
- /locate/ Wifi locate.
-
- How to use the /locate/ endpoint:
- POST visible wifi stations as JSON data like this:
- {
- "stations": [
- {
- "bssid": "11:22:33:44:55:66",
- "ssid": "36C3",
- "level": -55,
- "frequency": 5500
- },
- ...
- ]
- }
- """
- @action(detail=False, methods=['get', 'post'])
- def route(self, request, *args, **kwargs):
- params = request.POST if request.method == 'POST' else request.GET
- form = RouteForm(params, request=request)
-
- if not form.is_valid():
- return Response({
- 'errors': form.errors,
- }, status=400)
-
- options = RouteOptions.get_for_request(request)
- try:
- options.update(params, ignore_unknown=True)
- except ValidationError as e:
- return Response({
- 'errors': (str(e), ),
- }, status=400)
-
- try:
- route = Router.load().get_route(origin=form.cleaned_data['origin'],
- destination=form.cleaned_data['destination'],
- permissions=AccessPermission.get_for_request(request),
- options=options)
- except NotYetRoutable:
- return Response({
- 'error': _('Not yet routable, try again shortly.'),
- })
- except LocationUnreachable:
- return Response({
- 'error': _('Unreachable location.'),
- })
- except NoRouteFound:
- return Response({
- '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 Response({
- 'request': {
- 'origin': self.get_request_pk(form.cleaned_data['origin']),
- 'destination': self.get_request_pk(form.cleaned_data['destination']),
- },
- 'options': options.serialize(),
- 'report_issue_url': reverse('site.report_create', kwargs={
- 'origin': request.POST['origin'],
- 'destination': request.POST['destination'],
- 'options': options.serialize_string()
- }),
- 'result': route.serialize(locations=visible_locations_for_request(request)),
- })
-
- def get_request_pk(self, location):
- return location.slug if isinstance(location, Position) else location.pk
-
- @action(detail=False, methods=['get', 'post'])
- def options(self, request, *args, **kwargs):
- options = RouteOptions.get_for_request(request)
-
- if request.method == 'POST':
- try:
- options.update(request.POST, ignore_unknown=True)
- except ValidationError as e:
- return Response({
- 'errors': (str(e),),
- }, status=400)
- options.save()
-
- return Response(options.serialize())
-
- @action(detail=False, methods=('POST', ))
- def locate(self, request, *args, **kwargs):
- if isinstance(request.data, list):
- stations_data = request.data
- data = {}
- else:
- data = request.data
- if 'stations' not in data:
- return Response({
- 'errors': (_('stations is missing.'),),
- }, status=400)
- stations_data = data['stations']
-
- try:
- location = Locator.load().locate(stations_data, permissions=AccessPermission.get_for_request(request))
- if location is not None:
- increment_cache_key('apistats__locate__%s' % location.pk)
- except ValidationError:
- return Response({
- 'errors': (_('Invalid scan data.'),),
- }, status=400)
-
- # if 'set_position' in data and location:
- # set_position = data['set_position']
- # if not set_position.startswith('p:'):
- # return Response({
- # 'errors': (_('Invalid set_position.'),),
- # }, status=400)
- #
- # try:
- # position = Position.objects.get(secret=set_position[2:])
- # except Position.DoesNotExist:
- # return Response({
- # 'errors': (_('Invalid set_position.'),),
- # }, status=400)
- #
- # form_data = {
- # **data,
- # 'coordinates_id': None if location is None else location.pk,
- # }
- #
- # # todo: migrate
- # # form = PositionAPIUpdateForm(instance=position, data=form_data, request=request)
- # #
- # # if not form.is_valid():
- # # return Response({
- # # 'errors': form.errors,
- # # }, status=400)
- # #
- # # form.save()
-
- return Response({'location': None if location is None else location.serialize(simple_geometry=True)})
-
- @action(detail=False)
- def locate_test(self, request):
- from c3nav.mesh.messages import MeshMessageType
- from c3nav.mesh.models import MeshNode
- try:
- node = MeshNode.objects.prefetch_last_messages(MeshMessageType.LOCATE_RANGE_RESULTS).get(
- address="d4:f9:8d:2d:0d:f1"
- )
- except MeshNode.DoesNotExist:
- return Response({
- "location": None
- })
- msg = node.last_messages[MeshMessageType.LOCATE_RANGE_RESULTS]
-
- locator = RangeLocator.load()
- location = locator.locate(
- {
- r.peer: r.distance
- for r in msg.parsed.ranges
- if r.distance != 0xFFFF
- },
- None
- )
- return Response({
- "ranges": msg.parsed.tojson(msg.parsed)["ranges"],
- "datetime": msg.datetime,
- "location": location.serialize(simple_geometry=True) if location else None
- })
diff --git a/src/c3nav/routing/newapi/routing.py b/src/c3nav/routing/newapi/routing.py
index 9e488787..9d10f1ce 100644
--- a/src/c3nav/routing/newapi/routing.py
+++ b/src/c3nav/routing/newapi/routing.py
@@ -10,11 +10,11 @@ from ninja import Schema
from pydantic import PositiveInt
from c3nav.api.exceptions import APIRequestValidationFailed
-from c3nav.api.newauth import auth_responses, validate_responses, APITokenAuth
+from c3nav.api.newauth import APITokenAuth, auth_responses, validate_responses
from c3nav.api.utils import NonEmptyStr
-from c3nav.mapdata.api import api_stats_clean_location_value
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.models.locations import Position
+from c3nav.mapdata.newapi.base import api_stats_clean_location_value
from c3nav.mapdata.schemas.model_base import AnyLocationID, Coordinates3D
from c3nav.mapdata.utils.cache.stats import increment_cache_key
from c3nav.mapdata.utils.locations import visible_locations_for_request
diff --git a/src/c3nav/settings.py b/src/c3nav/settings.py
index 672d0364..a8fe5d02 100644
--- a/src/c3nav/settings.py
+++ b/src/c3nav/settings.py
@@ -302,7 +302,6 @@ INSTALLED_APPS = [
'bootstrap3',
'ninja',
'c3nav.api',
- 'rest_framework',
'c3nav.mapdata',
'c3nav.routing',
'c3nav.site',
@@ -372,16 +371,6 @@ USE_I18N = True
USE_L10N = True
USE_TZ = True
-REST_FRAMEWORK = {
- 'DEFAULT_AUTHENTICATION_CLASSES': (
- 'rest_framework.authentication.SessionAuthentication',
- 'c3nav.api.auth.APISecretAuthentication',
- ),
- 'DEFAULT_PERMISSION_CLASSES': (
- 'rest_framework.permissions.AllowAny',
- )
-}
-
NINJA_PAGINATION_CLASS = "ninja.pagination.LimitOffsetPagination"
LOCALE_PATHS = (
diff --git a/src/c3nav/urls.py b/src/c3nav/urls.py
index a3c64dbd..c5626641 100644
--- a/src/c3nav/urls.py
+++ b/src/c3nav/urls.py
@@ -15,8 +15,7 @@ import c3nav.site.urls
urlpatterns = [
path('editor/', include(c3nav.editor.urls)),
- path('api/v2/', c3nav.api.urls.ninja_api.urls),
- path('api/', include((c3nav.api.urls, 'api'), namespace='api')),
+ path('api/', include(c3nav.api.urls)),
path('map/', include(c3nav.mapdata.urls)),
path('admin/', admin.site.urls),
path('control/', include(c3nav.control.urls)),
diff --git a/src/requirements/production.txt b/src/requirements/production.txt
index 7c00a862..d1848eea 100644
--- a/src/requirements/production.txt
+++ b/src/requirements/production.txt
@@ -2,7 +2,6 @@ Django==4.2.7
django-bootstrap3==23.4
django-compressor==4.4
csscompressor==0.9.5
-djangorestframework==3.14.0
django-ninja==1.0.1
django-filter==23.4
django-environ==0.11.2