diff --git a/src/c3nav/editor/api.py b/src/c3nav/editor/api.py index d53d3c1f..865df29d 100644 --- a/src/c3nav/editor/api.py +++ b/src/c3nav/editor/api.py @@ -2,6 +2,7 @@ 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 ugettext_lazy as _ from rest_framework.decorators import detail_route, list_route from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError @@ -266,13 +267,19 @@ class EditorViewSet(ViewSet): '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): return self.retrieve(request, *args, **kwargs) - def retrieve(self, request, *args, **kwargs): - if not can_access_editor(request): - return PermissionDenied - + @cached_property + def resolved(self): resolved = None path = self.kwargs.get('path', '') if path: @@ -285,13 +292,23 @@ class EditorViewSet(ViewSet): try: resolved = resolve('/editor/'+path) 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): raise NotFound(_('Matching editor view point does not provide an API.')) - response = resolved.func(request, *resolved.args, **resolved.kwargs) - return Response(str(response)) + response = resolved.func(request, api=True, *resolved.args, **resolved.kwargs) + return response class ChangeSetViewSet(ReadOnlyModelViewSet): diff --git a/src/c3nav/editor/views/base.py b/src/c3nav/editor/views/base.py index 7bbeb270..9056b094 100644 --- a/src/c3nav/editor/views/base.py +++ b/src/c3nav/editor/views/base.py @@ -1,25 +1,33 @@ +from abc import ABC, abstractmethod +from collections import OrderedDict 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.http import HttpResponseNotModified, HttpResponseRedirect -from django.shortcuts import render +from django.http import HttpResponse, HttpResponseNotModified, HttpResponseRedirect +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 ugettext_lazy as _ +from rest_framework.response import Response as APIResponse 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, api_hybrid=False): +def sidebar_view(func=None, select_related=None, api_hybrid=False, allow_post=True, allow_delete=True): if func is None: 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 @wraps(func) 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!') 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' patch_vary_headers(response, ('X-Requested-With', )) return response + wrapped.api_hybrid = api_hybrid + wrapped.allow_post = allow_post + wrapped.allow_delete = allow_delete 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): 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): 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): diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py index 73aee40d..121b9fde 100644 --- a/src/c3nav/editor/views/edit.py +++ b/src/c3nav/editor/views/edit.py @@ -5,7 +5,6 @@ from contextlib import suppress from django.conf import settings from django.contrib import messages -from django.contrib.auth.views import redirect_to_login from django.core.cache import cache from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, PermissionDenied 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 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.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 -@sidebar_view +@sidebar_view(api_hybrid=True, allow_post=True, allow_delete=True) @etag(etag_func) def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False): 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 if changeset_exceeded: if new: - messages.error(request, _('You can not create new objects because your changeset is full.')) - return redirect(ctx['back_url']) + return APIHybridMessageRedirectResponse( + 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: messages.warning(request, _('You can not edit this object because your changeset is full.')) 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: - messages.info(request, _('You need to log in to create Wifi Measurements.')) - return redirect_to_login(request.path_info, 'editor.login') + return APIHybridLoginRequiredResponse(next=request.path_info, login_url='editor.login', level='info', + message=_('You need to log in to create Wifi Measurements.')) + error = None if request.method == 'POST': if nosave: - messages.error(request, _('You can not edit this object because your changeset is full.')) - return redirect(request.path) + return APIHybridMessageRedirectResponse( + level='error', message=_('You can not edit this object because your changeset is full.'), + redirect_to=request.path + ) if not can_edit: - messages.error(request, _('You can not edit this object.')) - return redirect(request.path) + return APIHybridMessageRedirectResponse( + level='error', message=_('You can not edit this object.'), + redirect_to=request.path + ) if not new and request.POST.get('delete') == '1': # 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(): raise PermissionError except (ObjectDoesNotExist, PermissionError): - messages.error(request, _('You can not delete this object because other objects still depend on it.')) - return redirect(request.path) + return APIHybridMessageRedirectResponse( + 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': with request.changeset.lock_to_edit(request) as changeset: if changeset.can_edit(request): obj.delete() else: - messages.error(request, _('You can not edit changes on this changeset.')) - return redirect(request.path) - messages.success(request, _('Object was successfully deleted.')) + return APIHybridMessageRedirectResponse( + level='error', + message=_('You can not edit changes on this changeset.'), + redirect_to=request.path + ) + redirect_to = None if model == Level: if obj.on_top_of_id is not None: - return redirect(reverse('editor.levels.detail', kwargs={'pk': obj.on_top_of_id})) - return redirect(reverse('editor.index')) + redirect_to = reverse('editor.levels.detail', kwargs={'pk': obj.on_top_of_id}) + else: + redirect_to = reverse('editor.index') elif model == Space: - return redirect(reverse('editor.spaces.list', kwargs={'level': obj.level.pk})) - return redirect(ctx['back_url']) + redirect_to = reverse('editor.spaces.list', kwargs={'level': obj.level.pk}) + else: + redirect_to = ctx['back_url'] + return APIHybridMessageRedirectResponse( + level='success', + message=_('Object was successfully deleted.'), + redirect_to=redirect_to + ) ctx['obj_title'] = obj.title 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: obj.save() except IntegrityError: - messages.error(request, _('Duplicate entry.')) + error = APIHybridError(status_code=400, message=_('Duplicate entry.')) else: if form.redirect_slugs is not None: 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() form.save_m2m() - messages.success(request, _('Object was successfully saved.')) - return redirect(ctx['back_url']) + return APIHybridMessageRedirectResponse( + level='success', + message=_('Object was successfully saved.'), + redirect_to=ctx['back_url'] + ) 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: 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, }) - return render(request, 'editor/edit.html', ctx) + return APIHybridFormTemplateResponse('editor/edit.html', ctx, form=form, error=error) def get_visible_spaces(request):