editor edit API

This commit is contained in:
Laura Klünder 2018-11-21 21:49:49 +01:00
parent 9be630c0c6
commit badda67549
3 changed files with 230 additions and 39 deletions

View file

@ -2,6 +2,7 @@ from itertools import chain
from django.db.models import Prefetch, Q from django.db.models import Prefetch, Q
from django.urls import Resolver404, resolve from django.urls import Resolver404, resolve
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework.decorators import detail_route, list_route from rest_framework.decorators import detail_route, list_route
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
@ -266,13 +267,19 @@ class EditorViewSet(ViewSet):
'bounds': Source.max_bounds(), 'bounds': Source.max_bounds(),
}) })
def __getattr__(self, name):
# allow POST and DELETE methods for the editor API
if name in ('post', 'delete'):
if getattr(self.resolved.func, 'allow_'+name, False):
if getattr(self, 'get', None).__name__ in ('list', 'retrieve'):
return self.retrieve
raise AttributeError
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs) return self.retrieve(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs): @cached_property
if not can_access_editor(request): def resolved(self):
return PermissionDenied
resolved = None resolved = None
path = self.kwargs.get('path', '') path = self.kwargs.get('path', '')
if path: if path:
@ -285,13 +292,23 @@ class EditorViewSet(ViewSet):
try: try:
resolved = resolve('/editor/'+path) resolved = resolve('/editor/'+path)
except Resolver404: except Resolver404:
raise NotFound(_('No matching editor view endpoint found.')) pass
return resolved
def retrieve(self, request, *args, **kwargs):
if not can_access_editor(request):
return PermissionDenied
resolved = self.resolved
if not resolved:
raise NotFound(_('No matching editor view endpoint found.'))
if not getattr(resolved.func, 'api_hybrid', False): if not getattr(resolved.func, 'api_hybrid', False):
raise NotFound(_('Matching editor view point does not provide an API.')) raise NotFound(_('Matching editor view point does not provide an API.'))
response = resolved.func(request, *resolved.args, **resolved.kwargs) response = resolved.func(request, api=True, *resolved.args, **resolved.kwargs)
return Response(str(response)) return response
class ChangeSetViewSet(ReadOnlyModelViewSet): class ChangeSetViewSet(ReadOnlyModelViewSet):

View file

@ -1,25 +1,33 @@
from abc import ABC, abstractmethod
from collections import OrderedDict
from functools import wraps from functools import wraps
from typing import Optional
from django.contrib import messages
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponseNotModified, HttpResponseRedirect from django.http import HttpResponse, HttpResponseNotModified, HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import redirect, render
from django.utils.cache import patch_vary_headers from django.utils.cache import patch_vary_headers
from django.utils.translation import get_language from django.utils.translation import get_language
from django.utils.translation import ugettext_lazy as _
from rest_framework.response import Response as APIResponse
from c3nav.editor.models import ChangeSet from c3nav.editor.models import ChangeSet
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.utils.user import can_access_editor from c3nav.mapdata.utils.user import can_access_editor
def sidebar_view(func=None, select_related=None, api_hybrid=False): def sidebar_view(func=None, select_related=None, api_hybrid=False, allow_post=True, allow_delete=True):
if func is None: if func is None:
def wrapped(inner_func): def wrapped(inner_func):
return sidebar_view(inner_func, select_related=select_related, api_hybrid=api_hybrid) return sidebar_view(inner_func, select_related=select_related, api_hybrid=api_hybrid,
allow_post=True, allow_delete=True)
return wrapped return wrapped
@wraps(func) @wraps(func)
def wrapped(request, *args, api=False, **kwargs): def wrapped(request, *args, api=False, **kwargs):
if api and api_hybrid: if api and not api_hybrid:
raise Exception('API call on a view without api_hybrid!') raise Exception('API call on a view without api_hybrid!')
if not can_access_editor(request): if not can_access_editor(request):
@ -53,19 +61,162 @@ def sidebar_view(func=None, select_related=None, api_hybrid=False):
response['Cache-Control'] = 'no-cache' response['Cache-Control'] = 'no-cache'
patch_vary_headers(response, ('X-Requested-With', )) patch_vary_headers(response, ('X-Requested-With', ))
return response return response
wrapped.api_hybrid = api_hybrid wrapped.api_hybrid = api_hybrid
wrapped.allow_post = allow_post
wrapped.allow_delete = allow_delete
return wrapped return wrapped
class APIHybridResponse(ABC):
status_code = None
etag = None
last_modified = None
def has_header(self, header):
header = header.lower()
if header == 'etag':
return self.etag is not None
elif header == 'last-modified':
return self.last_modified is not None
else:
raise KeyError
def __setitem__(self, header, value):
header = header.lower()
if header == 'etag':
self.etag = value
elif header == 'last-modified':
self.last_modified = value
else:
raise KeyError
def setdefault(self, header, value):
if not self.has_header(header):
self[header] = value
@abstractmethod
def get_api_response(self, request):
pass
@abstractmethod
def get_html_response(self, request):
pass
class APIHybridMessageRedirectResponse(APIHybridResponse):
def __init__(self, level, message, redirect_to):
self.level = level
self.message = message
self.redirect_to = self.redirect_to
def get_api_response(self, request):
return {self.level: self.message}
def get_html_response(self, request):
getattr(messages, self.level)(request, self.message)
return redirect(self.redirect_to)
class APIHybridLoginRequiredResponse(APIHybridResponse):
def __init__(self, next, login_url=None, level='error', message=_('Log in required.')):
self.login_url = login_url
self.next = next
self.level = level
self.message = message
def get_api_response(self, request):
return {self.level: self.message}
def get_html_response(self, request):
getattr(messages, self.level)(request, self.message)
return redirect_to_login(self.next, self.login_url)
class APIHybridError:
def __init__(self, status_code: int, message):
self.status_code = status_code
self.message = message
class APIHybridFormTemplateResponse(APIHybridResponse):
type_mapping = {
'TextInput': 'text',
'NumberInput': 'number',
'Textarea': 'text',
'CheckboxInput': 'boolean',
'Select': 'single_choice',
'SelectMultiple': 'multiple_choice',
}
def __init__(self, template: str, ctx: dict, form, error: Optional[APIHybridError]):
self.template = template
self.ctx = ctx
self.form = form
self.error = error
def get_api_response(self, request):
result = {}
if self.error:
result['error'] = str(self.error.message)
self.status_code = self.error.status_code
if request.method == 'POST':
if not self.form.is_valid():
if not self.form.is_valid() and self.status_code is None:
self.status_code = 400
result['form_errors'] = self.form.errors
else:
form = OrderedDict()
for name, field in self.form.fields.items():
widget = field.widget
field = {
'type': self.type_mapping[type(widget).__name__],
"required": field.required
}
if hasattr(widget, 'choices'):
field['choices'] = dict(widget.choices)
field.update(widget.attrs)
field.update({
'value': self.form[name].value(),
})
form[name] = field
result['form'] = form
return result
def get_html_response(self, request):
if self.error:
messages.error(request, self.error.message)
return render(request, self.template, self.ctx)
class NoAPIHybridResponse(Exception):
pass
def call_api_hybrid_view_for_api(func, request, *args, **kwargs): def call_api_hybrid_view_for_api(func, request, *args, **kwargs):
response = func(request, *args, **kwargs) response = func(request, *args, **kwargs)
return response if isinstance(response, APIHybridResponse):
api_response = APIResponse(response.get_api_response(request), status=response.status_code)
if response.etag:
api_response['ETag'] = response.etag
if response.last_modified:
api_response['Last-Modified'] = response.last_modified
return api_response
elif isinstance(response, HttpResponse) and response.status_code in (304, 412):
# 304 Not Modified, 412 Precondition Failed
return response
raise NoAPIHybridResponse
def call_api_hybrid_view_for_html(func, request, *args, **kwargs): def call_api_hybrid_view_for_html(func, request, *args, **kwargs):
response = func(request, *args, **kwargs) response = func(request, *args, **kwargs)
return response if isinstance(response, APIHybridResponse):
return response.get_html_response(request)
elif isinstance(response, HttpResponse) and response.status_code in (304, 412):
# 304 Not Modified, 412 Precondition Failed
return response
raise NoAPIHybridResponse
def etag_func(request, *args, **kwargs): def etag_func(request, *args, **kwargs):

View file

@ -5,7 +5,6 @@ from contextlib import suppress
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.views import redirect_to_login
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, PermissionDenied from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, PermissionDenied
from django.db import IntegrityError, models from django.db import IntegrityError, models
@ -17,7 +16,8 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.http import etag from django.views.decorators.http import etag
from c3nav.editor.forms import GraphEdgeSettingsForm, GraphEditorActionForm from c3nav.editor.forms import GraphEdgeSettingsForm, GraphEditorActionForm
from c3nav.editor.views.base import etag_func, sidebar_view from c3nav.editor.views.base import (APIHybridError, APIHybridFormTemplateResponse, APIHybridLoginRequiredResponse,
APIHybridMessageRedirectResponse, etag_func, sidebar_view)
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.utils.user import can_access_editor from c3nav.mapdata.utils.user import can_access_editor
@ -121,7 +121,7 @@ def get_changeset_exceeded(request):
return request.user_permissions.max_changeset_changes <= request.changeset.changed_objects_count return request.user_permissions.max_changeset_changes <= request.changeset.changed_objects_count
@sidebar_view @sidebar_view(api_hybrid=True, allow_post=True, allow_delete=True)
@etag(etag_func) @etag(etag_func)
def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False): def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False):
changeset_exceeded = get_changeset_exceeded(request) changeset_exceeded = get_changeset_exceeded(request)
@ -258,8 +258,10 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
nosave = False nosave = False
if changeset_exceeded: if changeset_exceeded:
if new: if new:
messages.error(request, _('You can not create new objects because your changeset is full.')) return APIHybridMessageRedirectResponse(
return redirect(ctx['back_url']) level='error', message=_('You can not create new objects because your changeset is full.'),
redirect_to=ctx['back_url']
)
elif obj.pk not in model_changes: elif obj.pk not in model_changes:
messages.warning(request, _('You can not edit this object because your changeset is full.')) messages.warning(request, _('You can not edit this object because your changeset is full.'))
nosave = True nosave = True
@ -274,17 +276,22 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
}) })
if new and model.__name__ == 'WifiMeasurement' and not request.user.is_authenticated: if new and model.__name__ == 'WifiMeasurement' and not request.user.is_authenticated:
messages.info(request, _('You need to log in to create Wifi Measurements.')) return APIHybridLoginRequiredResponse(next=request.path_info, login_url='editor.login', level='info',
return redirect_to_login(request.path_info, 'editor.login') message=_('You need to log in to create Wifi Measurements.'))
error = None
if request.method == 'POST': if request.method == 'POST':
if nosave: if nosave:
messages.error(request, _('You can not edit this object because your changeset is full.')) return APIHybridMessageRedirectResponse(
return redirect(request.path) level='error', message=_('You can not edit this object because your changeset is full.'),
redirect_to=request.path
)
if not can_edit: if not can_edit:
messages.error(request, _('You can not edit this object.')) return APIHybridMessageRedirectResponse(
return redirect(request.path) level='error', message=_('You can not edit this object.'),
redirect_to=request.path
)
if not new and request.POST.get('delete') == '1': if not new and request.POST.get('delete') == '1':
# Delete this mapitem! # Delete this mapitem!
@ -292,24 +299,37 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
if not request.changeset.get_changed_object(obj).can_delete(): if not request.changeset.get_changed_object(obj).can_delete():
raise PermissionError raise PermissionError
except (ObjectDoesNotExist, PermissionError): except (ObjectDoesNotExist, PermissionError):
messages.error(request, _('You can not delete this object because other objects still depend on it.')) return APIHybridMessageRedirectResponse(
return redirect(request.path) level='error',
message=_('You can not delete this object because other objects still depend on it.'),
redirect_to=request.path
)
if request.POST.get('delete_confirm') == '1': if request.POST.get('delete_confirm') == '1':
with request.changeset.lock_to_edit(request) as changeset: with request.changeset.lock_to_edit(request) as changeset:
if changeset.can_edit(request): if changeset.can_edit(request):
obj.delete() obj.delete()
else: else:
messages.error(request, _('You can not edit changes on this changeset.')) return APIHybridMessageRedirectResponse(
return redirect(request.path) level='error',
messages.success(request, _('Object was successfully deleted.')) message=_('You can not edit changes on this changeset.'),
redirect_to=request.path
)
redirect_to = None
if model == Level: if model == Level:
if obj.on_top_of_id is not None: if obj.on_top_of_id is not None:
return redirect(reverse('editor.levels.detail', kwargs={'pk': obj.on_top_of_id})) redirect_to = reverse('editor.levels.detail', kwargs={'pk': obj.on_top_of_id})
return redirect(reverse('editor.index')) else:
redirect_to = reverse('editor.index')
elif model == Space: elif model == Space:
return redirect(reverse('editor.spaces.list', kwargs={'level': obj.level.pk})) redirect_to = reverse('editor.spaces.list', kwargs={'level': obj.level.pk})
return redirect(ctx['back_url']) else:
redirect_to = ctx['back_url']
return APIHybridMessageRedirectResponse(
level='success',
message=_('Object was successfully deleted.'),
redirect_to=redirect_to
)
ctx['obj_title'] = obj.title ctx['obj_title'] = obj.title
return render(request, 'editor/delete.html', ctx) return render(request, 'editor/delete.html', ctx)
@ -333,7 +353,7 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
try: try:
obj.save() obj.save()
except IntegrityError: except IntegrityError:
messages.error(request, _('Duplicate entry.')) error = APIHybridError(status_code=400, message=_('Duplicate entry.'))
else: else:
if form.redirect_slugs is not None: if form.redirect_slugs is not None:
for slug in form.add_redirect_slugs: for slug in form.add_redirect_slugs:
@ -343,10 +363,13 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
obj.redirects.filter(slug=slug).delete() obj.redirects.filter(slug=slug).delete()
form.save_m2m() form.save_m2m()
messages.success(request, _('Object was successfully saved.')) return APIHybridMessageRedirectResponse(
return redirect(ctx['back_url']) level='success',
message=_('Object was successfully saved.'),
redirect_to=ctx['back_url']
)
else: else:
messages.error(request, _('You can not edit changes on this changeset.')) error = APIHybridError(status_code=403, message=_('You can not edit changes on this changeset.'))
else: else:
form = model.EditorForm(instance=obj, request=request, space_id=space_id, form = model.EditorForm(instance=obj, request=request, space_id=space_id,
@ -356,7 +379,7 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e
'form': form, 'form': form,
}) })
return render(request, 'editor/edit.html', ctx) return APIHybridFormTemplateResponse('editor/edit.html', ctx, form=form, error=error)
def get_visible_spaces(request): def get_visible_spaces(request):