add new data and editor permissions

This commit is contained in:
Laura Klünder 2018-09-19 19:08:47 +02:00
parent 8ffa982882
commit b88b6c3a29
18 changed files with 160 additions and 60 deletions

View file

@ -1,3 +1,4 @@
import inspect
import re
from collections import OrderedDict
@ -14,6 +15,7 @@ from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionV
LocationGroupCategoryViewSet, LocationGroupViewSet, LocationViewSet, MapViewSet,
ObstacleViewSet, POIViewSet, RampViewSet, SourceViewSet, SpaceViewSet, StairViewSet,
UpdatesViewSet)
from c3nav.mapdata.utils.user import can_access_editor
from c3nav.routing.api import RoutingViewSet
router = SimpleRouter()
@ -62,8 +64,11 @@ class APIRoot(GenericAPIView):
@cached_property
def urls(self):
include_editor = can_access_editor(self.request)
urls = 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]

View file

@ -0,0 +1,23 @@
# Generated by Django 2.1.1 on 2018-09-19 15:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('control', '0004_more_user_permissions'),
]
operations = [
migrations.AddField(
model_name='userpermissions',
name='base_mapdata_access',
field=models.BooleanField(default=False, verbose_name='can always access base map data'),
),
migrations.AddField(
model_name='userpermissions',
name='editor_access',
field=models.BooleanField(default=False, verbose_name='can always access editor'),
),
]

View file

@ -14,6 +14,8 @@ class UserPermissions(models.Model):
review_changesets = models.BooleanField(default=False, verbose_name=_('can review changesets'))
direct_edit = models.BooleanField(default=False, verbose_name=_('can activate direct editing'))
max_changeset_changes = models.PositiveSmallIntegerField(default=10, verbose_name=_('max changes per changeset'))
editor_access = models.BooleanField(default=False, verbose_name=_('can always access editor'))
base_mapdata_access = models.BooleanField(default=False, verbose_name=_('can always access base map data'))
control_panel = models.BooleanField(default=False, verbose_name=_('can access control panel'))
grant_permissions = models.BooleanField(default=False, verbose_name=_('can grant control permissions'))

View file

@ -2,7 +2,7 @@ from itertools import chain
from django.db.models import Prefetch, Q
from rest_framework.decorators import detail_route, list_route
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
@ -13,6 +13,7 @@ from c3nav.editor.views.base import etag_func
from c3nav.mapdata.api import api_etag
from c3nav.mapdata.models import Area, Door, MapUpdate, Source
from c3nav.mapdata.models.geometry.space import POI
from c3nav.mapdata.utils.user import can_access_editor
class EditorViewSet(ViewSet):
@ -71,6 +72,9 @@ class EditorViewSet(ViewSet):
@list_route(methods=['get'])
@api_etag(etag_func=etag_func, cache_parameters={'level': str, 'space': str})
def geometries(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
Level = request.changeset.wrap_model('Level')
Space = request.changeset.wrap_model('Space')
@ -209,6 +213,9 @@ class EditorViewSet(ViewSet):
@list_route(methods=['get'])
@api_etag(etag_func=MapUpdate.current_cache_key, cache_parameters={})
def geometrystyles(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
return Response({
'building': '#aaaaaa',
'space': '#eeeeee',
@ -231,6 +238,9 @@ class EditorViewSet(ViewSet):
@list_route(methods=['get'])
@api_etag(etag_func=etag_func, cache_parameters={})
def bounds(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
return Response({
'bounds': Source.max_bounds(),
})
@ -247,18 +257,26 @@ class ChangeSetViewSet(ReadOnlyModelViewSet):
return ChangeSet.qs_for_request(self.request).select_related('last_update', 'last_state_update', 'last_change')
def list(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
return Response([obj.serialize() for obj in self.get_queryset().order_by('id')])
def retrieve(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
return Response(self.get_object().serialize())
@list_route(methods=['get'])
def current(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
changeset = ChangeSet.get_for_request(request)
return Response(changeset.serialize())
@detail_route(methods=['get'])
def changes(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
changeset = self.get_object()
changeset.fill_changes_cache()
return Response([obj.serialize() for obj in changeset.iter_changed_objects()])

View file

@ -1,5 +1,6 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseNotModified, HttpResponseRedirect
from django.shortcuts import render
from django.utils.cache import patch_vary_headers
@ -7,6 +8,7 @@ from django.utils.translation import get_language
from c3nav.editor.models import ChangeSet
from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.utils.user import can_access_editor
def sidebar_view(func=None, select_related=None):
@ -17,6 +19,9 @@ def sidebar_view(func=None, select_related=None):
@wraps(func)
def with_ajax_check(request, *args, **kwargs):
if not can_access_editor(request):
raise PermissionDenied
request.changeset = ChangeSet.get_for_request(request, select_related)
ajax = request.is_ajax() or 'ajax' in request.GET

View file

@ -29,7 +29,7 @@ from c3nav.mapdata.models.locations import (Location, LocationGroupCategory, Loc
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 get_user_data
from c3nav.mapdata.utils.user import can_access_base_mapdata, can_access_editor, get_user_data
from c3nav.mapdata.views import set_tile_access_cookie
@ -42,7 +42,7 @@ def optimize_query(qs):
return qs
def api_etag(permissions=True, etag_func=AccessPermission.etag_func, cache_parameters=None):
def api_etag(permissions=True, etag_func=AccessPermission.etag_func, cache_parameters=None, base_mapdata_check=False):
def wrapper(func):
@wraps(func)
def wrapped_func(self, request, *args, **kwargs):
@ -50,6 +50,8 @@ def api_etag(permissions=True, etag_func=AccessPermission.etag_func, cache_param
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' % can_access_base_mapdata(request)
etag = quote_etag(raw_etag)
response = get_conditional_response(request, etag=etag)
@ -68,8 +70,9 @@ def api_etag(permissions=True, etag_func=AccessPermission.etag_func, cache_param
if cache_parameters is not None and response.status_code == 200:
cache.set(cache_key, response.data, 300)
response['ETag'] = etag
response['Cache-Control'] = 'no-cache'
if response.status_code == 200:
response['ETag'] = etag
response['Cache-Control'] = 'no-cache'
return response
return wrapped_func
return wrapper
@ -90,6 +93,7 @@ class MapViewSet(ViewSet):
class MapdataViewSet(ReadOnlyModelViewSet):
base_mapdata = False
order_by = ('id', )
def get_queryset(self):
@ -98,6 +102,12 @@ class MapdataViewSet(ReadOnlyModelViewSet):
return qs.model.qs_for_request(self.request)
return qs
@staticmethod
def can_access_geometry(request, obj):
if isinstance(obj, (Building, Space, Door)):
return can_access_base_mapdata(request)
return True
qs_filter = namedtuple('qs_filter', ('field', 'model', 'key', 'value'))
def _get_keys_for_model(self, request, model, key):
@ -165,16 +175,19 @@ class MapdataViewSet(ReadOnlyModelViewSet):
cache.set(cache_key, results, 300)
return results
@api_etag()
@api_etag(base_mapdata_check=True)
def list(self, request, *args, **kwargs):
geometry = ('geometry' in request.GET)
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()
@api_etag(base_mapdata_check=True)
def retrieve(self, request, *args, **kwargs):
return Response(self.get_object().serialize())
obj = self.get_object()
return Response(obj.serialize(geometry=self.can_access_geometry(request, obj)))
@staticmethod
def list_types(models_list, **kwargs):
@ -197,16 +210,18 @@ class LevelViewSet(MapdataViewSet):
class BuildingViewSet(MapdataViewSet):
""" Add ?geometry=1 to get geometries, add ?level=<id> to filter by level. """
""" Add ?geometry=1 to get geometries if available, add ?level=<id> to filter by level. """
queryset = Building.objects.all()
base_mapdata = True
class SpaceViewSet(MapdataViewSet):
"""
Add ?geometry=1 to get geometries, add ?level=<id> to filter by level, add ?group=<id> to filter by group.
Add ?geometry=1 to get geometries if available, ?level=<id> to filter by level, ?group=<id> 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
@list_route(methods=['get'])
@api_etag(permissions=False, cache_parameters={})
@ -215,8 +230,9 @@ class SpaceViewSet(MapdataViewSet):
class DoorViewSet(MapdataViewSet):
""" Add ?geometry=1 to get geometries, add ?level=<id> to filter by level. """
""" Add ?geometry=1 to get geometries if available, add ?level=<id> to filter by level. """
queryset = Door.objects.all()
base_mapdata = True
class HoleViewSet(MapdataViewSet):
@ -287,11 +303,12 @@ class LocationGroupViewSet(MapdataViewSet):
class LocationViewSetBase(RetrieveModelMixin, GenericViewSet):
queryset = LocationSlug.objects.all()
base_mapdata = True
def get_object(self) -> LocationSlug:
raise NotImplementedError
@api_etag(cache_parameters={'show_redirects': bool, 'detailed': bool, 'geometry': bool})
@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
@ -307,10 +324,11 @@ class LocationViewSetBase(RetrieveModelMixin, GenericViewSet):
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, simple_geometry=True))
geometry=geometry and MapdataViewSet.can_access_geometry(request, location),
simple_geometry=True))
@detail_route(methods=['get'])
@api_etag()
@api_etag(base_mapdata_check=True)
def details(self, request, **kwargs):
location = self.get_object()
@ -320,7 +338,10 @@ class LocationViewSetBase(RetrieveModelMixin, GenericViewSet):
if isinstance(location, LocationRedirect):
return redirect('../' + str(location.target.pk) + '/details/')
return Response(location.details_display())
return Response(location.details_display(
detailed_geometry=MapdataViewSet.can_access_geometry(request, location),
editor_url=can_access_editor(request)
))
class LocationViewSet(LocationViewSetBase):
@ -332,7 +353,7 @@ class LocationViewSet(LocationViewSetBase):
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
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}/
"""
@ -342,24 +363,27 @@ class LocationViewSet(LocationViewSetBase):
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})
@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' % (
cache_key = 'mapdata:api:location:list:%d:%s:%d' % (
searchable + detailed*2 + geometry*4,
AccessPermission.cache_key_for_request(self.request)
AccessPermission.cache_key_for_request(request),
can_access_base_mapdata(request)
)
result = cache.get(cache_key, None)
if result is None:
if searchable:
locations = searchable_locations_for_request(self.request)
locations = searchable_locations_for_request(request)
else:
locations = visible_locations_for_request(self.request).values()
locations = visible_locations_for_request(request).values()
result = tuple(obj.serialize(include_type=True, detailed=detailed, geometry=geometry, simple_geometry=True)
result = tuple(obj.serialize(include_type=True, detailed=detailed,
geometry=geometry and MapdataViewSet.can_access_geometry(request, obj),
simple_geometry=True)
for obj in locations)
cache.set(cache_key, result, 300)

View file

@ -250,8 +250,8 @@ class AccessRestrictionMixin(SerializableMixin, models.Model):
result['access_restriction'] = self.access_restriction_id
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
result['display'].extend([
(_('Access Restriction'), self.access_restriction_id and self.access_restriction.title),
])

View file

@ -33,7 +33,7 @@ class SerializableMixin(models.Model):
result['id'] = self.pk
return result
def details_display(self):
def details_display(self, **kwargs):
return {
'id': self.pk,
'display': [
@ -72,8 +72,8 @@ class TitledMixin(SerializableMixin, models.Model):
result['title'] = self.title
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
for lang, title in sorted(self.titles.items(), key=lambda item: item[0] != get_language()):
language = _('Title ({lang})').format(lang=get_language_info(lang)['name_translated'])
result['display'].append((language, title))

View file

@ -2,7 +2,7 @@ import math
from collections import OrderedDict
from django.utils.functional import cached_property
from shapely.geometry import Point, mapping
from shapely.geometry import Point, box, mapping
from shapely.ops import unary_union
from c3nav.mapdata.models.base import SerializableMixin
@ -78,9 +78,12 @@ class GeometryMixin(SerializableMixin):
(int(math.ceil(maxx)), int(math.ceil(maxy))))
return result
def details_display(self):
result = super().details_display()
result['geometry'] = format_geojson(mapping(self.geometry), round=False)
def details_display(self, detailed_geometry=True, **kwargs):
result = super().details_display(**kwargs)
if detailed_geometry:
result['geometry'] = format_geojson(mapping(self.geometry), round=False)
else:
result['geometry'] = format_geojson(mapping(box(*self.geometry.bounds)), round=False)
return result
def get_shadow_geojson(self):

View file

@ -49,8 +49,8 @@ class LevelGeometryMixin(GeometryMixin):
result['level'] = self.level_id
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
result['display'].insert(3, (
_('Level'),
{
@ -126,13 +126,14 @@ class Space(LevelGeometryMixin, SpecificLocation, models.Model):
result['height'] = None if self.height is None else float(str(self.height))
return result
def details_display(self):
result = super().details_display()
def details_display(self, editor_url=True, **kwargs):
result = super().details_display(**kwargs)
result['display'].extend([
(_('height'), self.height),
(_('outside only'), _('Yes') if self.outside else _('No')),
])
result['editor_url'] = reverse('editor.spaces.detail', kwargs={'level': self.level_id, 'pk': self.pk})
if editor_url:
result['editor_url'] = reverse('editor.spaces.detail', kwargs={'level': self.level_id, 'pk': self.pk})
return result

View file

@ -73,8 +73,8 @@ class SpaceGeometryMixin(GeometryMixin):
self.geometry if force else self.get_changed_geometry()
))
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
result['display'].insert(3, (
_('Space'),
{
@ -125,9 +125,10 @@ class Area(SpaceGeometryMixin, SpecificLocation, models.Model):
result = super()._serialize(**kwargs)
return result
def details_display(self):
result = super().details_display()
result['editor_url'] = reverse('editor.areas.edit', kwargs={'space': self.space_id, 'pk': self.pk})
def details_display(self, editor_url=True, **kwargs):
result = super().details_display(**kwargs)
if editor_url:
result['editor_url'] = reverse('editor.areas.edit', kwargs={'space': self.space_id, 'pk': self.pk})
return result
@ -224,9 +225,10 @@ class POI(SpaceGeometryMixin, SpecificLocation, models.Model):
verbose_name_plural = _('Points of Interest')
default_related_name = 'pois'
def details_display(self):
result = super().details_display()
result['editor_url'] = reverse('editor.pois.edit', kwargs={'space': self.space_id, 'pk': self.pk})
def details_display(self, editor_url=True, **kwargs):
result = super().details_display(**kwargs)
if editor_url:
result['editor_url'] = reverse('editor.pois.edit', kwargs={'space': self.space_id, 'pk': self.pk})
return result
@property

View file

@ -77,14 +77,15 @@ class Level(SpecificLocation, models.Model):
result['door_height'] = float(str(self.door_height))
return result
def details_display(self):
result = super().details_display()
def details_display(self, editor_url=True, **kwargs):
result = super().details_display(**kwargs)
result['display'].insert(3, (_('short label'), self.short_label))
result['display'].extend([
(_('outside only'), self.base_altitude),
(_('default height'), self.default_height),
])
result['editor_url'] = reverse('editor.levels.detail', kwargs={'pk': self.pk})
if editor_url:
result['editor_url'] = reverse('editor.levels.detail', kwargs={'pk': self.pk})
return result
@cached_property

View file

@ -60,8 +60,8 @@ class LocationSlug(SerializableMixin, models.Model):
result['slug'] = self.get_slug()
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
result['display'].insert(2, (_('Slug'), str(self.get_slug())))
return result
@ -96,8 +96,8 @@ class Location(LocationSlug, AccessRestrictionMixin, TitledMixin, models.Model):
result['can_describe'] = self.can_search
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
result['display'].extend([
(_('searchable'), _('Yes') if self.can_search else _('No')),
(_('can describe'), _('Yes') if self.can_describe else _('No'))
@ -143,8 +143,8 @@ class SpecificLocation(Location, models.Model):
result['groups'] = groups
return result
def details_display(self):
result = super().details_display()
def details_display(self, **kwargs):
result = super().details_display(**kwargs)
groupcategories = {}
for group in self.groups.all():
@ -258,14 +258,15 @@ class LocationGroup(Location, models.Model):
result['locations'] = tuple(obj.pk for obj in getattr(self, 'locations', ()))
return result
def details_display(self):
result = super().details_display()
def details_display(self, editor_url=True, **kwargs):
result = super().details_display(**kwargs)
result['display'].insert(3, (_('Category'), self.category.title))
result['display'].extend([
(_('color'), self.color),
(_('priority'), self.priority),
])
result['editor_url'] = reverse('editor.locationgroups.edit', kwargs={'pk': self.pk})
if editor_url:
result['editor_url'] = reverse('editor.locationgroups.edit', kwargs={'pk': self.pk})
return result
@property

View file

@ -294,7 +294,7 @@ class CustomLocation:
return result
def details_display(self):
def details_display(self, **kwargs):
return {
'id': self.pk,
'display': [

View file

@ -1,3 +1,4 @@
from django.conf import settings
from django.utils.functional import lazy
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
@ -28,3 +29,11 @@ def get_user_data(request):
get_user_data_lazy = lazy(get_user_data, dict)
def can_access_base_mapdata(request):
return settings.PUBLIC_BASE_MAPDATA or request.user_permissions.base_mapdata_access
def can_access_editor(request):
return settings.PUBLIC_EDITOR or request.user_permissions.editor_access

View file

@ -41,6 +41,9 @@ if not os.path.exists(TILES_ROOT):
if not os.path.exists(CACHE_ROOT):
os.mkdir(CACHE_ROOT)
PUBLIC_EDITOR = config.get('c3nav', 'editor', fallback=True)
PUBLIC_BASE_MAPDATA = config.get('c3nav', 'public_base_mapdata', fallback=False)
if config.has_option('django', 'secret'):
SECRET_KEY = config.get('django', 'secret')
else:

View file

@ -10,7 +10,9 @@
{% get_current_language as CURRENT_LANGUAGE %}
<a href="{% url 'site.language' %}" id="choose-language">{{ CURRENT_LANGUAGE | language_name_local }}</a>
{% endif %}
<a href="{% url 'editor.index' %}" target="_blank">{% trans 'Editor' %}</a> //
{% if editor %}
<a href="{% url 'editor.index' %}" target="_blank">{% trans 'Editor' %}</a> //
{% endif %}
<a href="/api/" target="_blank">{% trans 'API' %}</a> //
<a href="https://twitter.com/c3nav/" rel="external" target="_blank">Twitter</a> //
<a href="https://github.com/c3nav/c3nav/" rel="external" target="_blank">GitHub</a>

View file

@ -26,7 +26,7 @@ from c3nav.mapdata.models import Location, Source
from c3nav.mapdata.models.access import AccessPermissionToken
from c3nav.mapdata.models.locations import LocationRedirect, SpecificLocation
from c3nav.mapdata.utils.locations import get_location_by_slug_for_request, levels_by_short_label_for_request
from c3nav.mapdata.utils.user import get_user_data
from c3nav.mapdata.utils.user import can_access_editor, get_user_data
from c3nav.mapdata.views import set_tile_access_cookie
from c3nav.site.models import Announcement, SiteUpdate
@ -123,6 +123,7 @@ def map_index(request, mode=None, slug=None, slug2=None, details=None, options=N
'initial_bounds': json.dumps(initial_bounds, separators=(',', ':')) if initial_bounds else None,
'last_site_update': json.dumps(SiteUpdate.last_update()),
'ssids': json.dumps(settings.WIFI_SSIDS, separators=(',', ':')) if settings.WIFI_SSIDS else None,
'editor': can_access_editor(request),
'embed': bool(embed),
}