diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 0411b0e7f7..66306fd583 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -46,6 +46,11 @@ included in this changelog. ### Modifications +- All APIs: errors during a request are now indicated with a more useful HTTP + status code than 200: errors related to the request, input data and the client + will result in status 400, permission errors in status 403, unavailable + resources in status 404 and internal server errors in status 500. + - `POST|GET /{project_id}/node/list`: Offers a new optional parameter "ordering", which can be used to order the result set of nodes. The values diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8f7537b2..6e03787ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ the tracing tool is now enabled for new users, because it seems in many setups users want to have this enabled by default. +- Back-end errors result now in actual HTTP error status codes. Third-party + clients need possibly some adjustments to handle API errors. In case of an + error, status 400 is returned if an input data or parameter problem, 401 for + permission problems and 500 otherwise. + ### Features and enhancements Node filters: diff --git a/UPDATE.md b/UPDATE.md index 17af2a385d..969a79c529 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -9,6 +9,11 @@ and other administration related changes are listed in order. PostGIS first and run `ALTER EXTENSION postgis UPDATE;` in every database. For docker-compose setups this database update is performed automatically. +- Back-end errors result now in actual HTTP error status codes. Third-party + clients need possibly some adjustments to handle API errors. In case of an + error, status 400 is returned if an input data or parameter problem, 401 for + permission problems and 500 otherwise. + ## 2019.06.20 - A virtualenv update is required. diff --git a/django/applications/catmaid/control/authentication.py b/django/applications/catmaid/control/authentication.py index a27acd1074..fbc5fe7e77 100644 --- a/django/applications/catmaid/control/authentication.py +++ b/django/applications/catmaid/control/authentication.py @@ -28,21 +28,22 @@ from rest_framework.authtoken import views as auth_views from rest_framework.authtoken.serializers import AuthTokenSerializer +from catmaid.error import ClientError from catmaid.models import Project, UserRole, ClassInstance, \ ClassInstanceClassInstance -class PermissionError(Exception): +class PermissionError(ClientError): """Indicates the lack of permissions for a particular action.""" - pass + status_code = 403 -class InvalidLoginError(Exception): +class InvalidLoginError(ClientError): """Indicates an unsuccessful login.""" - pass + status_code = 400 -class InactiveLoginError(Exception): +class InactiveLoginError(ClientError): """Indicates some sort of configuration error""" def __init__(self, message, meta=None): super().__init__(message) @@ -120,6 +121,7 @@ def user_context_response(user, additional_fields=None) -> JsonResponse: 'userid': user.id, 'username': user.username, 'is_superuser': user.is_superuser, + 'is_authenticated': user != get_anonymous_user(), 'userprofile': user.userprofile.as_dict(), 'permissions': tuple(user.get_all_permissions()), 'domain': list(user_domain(cursor, user.id)) @@ -386,7 +388,7 @@ def can_edit_class_instance_or_fail(user, ci_id, name='object') -> bool: locked_by_other[0].user_id): return True - raise Exception('User %s with id #%s cannot edit %s #%s' % \ + raise PermissionError('User %s with id #%s cannot edit %s #%s' % \ (user.username, user.id, name, ci_id)) # The class instance is locked by user or not locked at all return True @@ -419,7 +421,7 @@ def can_edit_or_fail(user, ob_id:int, table_name) -> bool: if user_can_edit(cursor, user.id, owner_id): return True - raise Exception('User %s with id #%s cannot edit object #%s (from user #%s) from table %s' % (user.username, user.id, ob_id, rows[0][0], table_name)) + raise PermissionError('User %s with id #%s cannot edit object #%s (from user #%s) from table %s' % (user.username, user.id, ob_id, rows[0][0], table_name)) raise ObjectDoesNotExist('Object #%s not found in table %s' % (ob_id, table_name)) @@ -448,7 +450,7 @@ def can_edit_all_or_fail(user, ob_ids, table_name) -> bool: if set(row[0] for row in rows).issubset(user_domain(cursor, user.id)): return True - raise Exception('User %s cannot edit all of the %s unique objects from table %s' % (user.username, len(ob_ids), table_name)) + raise PermissionError('User %s cannot edit all of the %s unique objects from table %s' % (user.username, len(ob_ids), table_name)) raise ObjectDoesNotExist('One or more of the %s unique objects were not found in table %s' % (len(ob_ids), table_name)) def user_can_edit(cursor, user_id, other_user_id) -> bool: diff --git a/django/applications/catmaid/control/pointcloud.py b/django/applications/catmaid/control/pointcloud.py index a8fc6b52fb..4c03fa18ff 100644 --- a/django/applications/catmaid/control/pointcloud.py +++ b/django/applications/catmaid/control/pointcloud.py @@ -17,7 +17,7 @@ from rest_framework.decorators import api_view from catmaid.control.authentication import (requires_user_role, - can_edit_or_fail, check_user_role) + can_edit_or_fail, check_user_role, PermissionError) from catmaid.control.common import (insert_into_log, get_class_to_id_map, get_relation_to_id_map, _create_relation, get_request_bool, get_request_list) diff --git a/django/applications/catmaid/control/similarity.py b/django/applications/catmaid/control/similarity.py index d3e4999e9c..7165dbabbe 100644 --- a/django/applications/catmaid/control/similarity.py +++ b/django/applications/catmaid/control/similarity.py @@ -20,7 +20,7 @@ from catmaid.consumers import msg_user from catmaid.control.authentication import (requires_user_role, - can_edit_or_fail, check_user_role) + can_edit_or_fail, check_user_role, PermissionError) from catmaid.control.common import (insert_into_log, get_class_to_id_map, get_relation_to_id_map, _create_relation, get_request_bool, get_request_list) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index f8fbc8fc7f..def8b99d9a 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -144,7 +144,7 @@ def _open_leaves(project_id, skeleton_id, tnid=None): tnid = node_id if tnid not in tree: - raise Exception("Could not find %s in skeleton %s" % (tnid, int(skeleton_id))) + raise ValueError("Could not find %s in skeleton %s" % (tnid, int(skeleton_id))) reroot(tree, tnid) distances = edge_count_to_root(tree, root_node=tnid) @@ -301,7 +301,7 @@ def _find_labels(project_id, skeleton_id, label_regex, tnid=None, props['tags'] = [row[5]] if tnid not in tree: - raise Exception("Could not find %s in skeleton %s" % (tnid, int(skeleton_id))) + raise ValueError("Could not find %s in skeleton %s" % (tnid, int(skeleton_id))) reroot(tree, tnid) distances = edge_count_to_root(tree, root_node=tnid) @@ -370,7 +370,7 @@ def within_spatial_distance(request:HttpRequest, project_id=None) -> JsonRespons project_id = int(project_id) tnid = request.POST.get('treenode_id', None) if not tnid: - raise Exception("Need a treenode!") + raise ValueError("Need a treenode!") tnid = int(tnid) distance = int(request.POST.get('distance', 0)) if 0 == distance: @@ -661,7 +661,7 @@ def _get_neuronname_from_skeletonid( project_id, skeleton_id ): return {'neuronname': qs[0].class_instance_b.name, 'neuronid': qs[0].class_instance_b.id } except IndexError: - raise Exception("Couldn't find a neuron linking to a skeleton with " \ + raise ValueError("Couldn't find a neuron linking to a skeleton with " \ "ID %s" % skeleton_id) @requires_user_role([UserRole.Annotate, UserRole.Browse]) @@ -1173,7 +1173,7 @@ def split_skeleton(request:HttpRequest, project_id=None) -> JsonResponse: if not check_annotations_on_split(project_id, skeleton_id, frozenset(upstream_annotation_map.keys()), frozenset(downstream_annotation_map.keys())): - raise Exception("Annotation distribution is not valid for splitting. " \ + raise ValueError("Annotation distribution is not valid for splitting. " \ "One part has to keep the whole set of annotations!") skeleton = ClassInstance.objects.select_related('user').get(pk=skeleton_id) @@ -1485,12 +1485,12 @@ def skeleton_ancestry(request:HttpRequest, project_id=None) -> JsonResponse: # prefetch_related when we upgrade to Django 1.4 or above skeleton_id = int(request.POST.get('skeleton_id', None)) if skeleton_id is None: - raise Exception('A skeleton id has not been provided!') + raise ValueError('A skeleton id has not been provided!') relation_map = get_relation_to_id_map(project_id) for rel in ['model_of', 'part_of']: if rel not in relation_map: - raise Exception(' => "Failed to find the required relation %s' % rel) + raise ValueError(' => "Failed to find the required relation %s' % rel) response_on_error = '' try: @@ -1502,9 +1502,9 @@ def skeleton_ancestry(request:HttpRequest, project_id=None) -> JsonResponse: 'class_instance_b__name') neuron_count = neuron_rows.count() if neuron_count == 0: - raise Exception('No neuron was found that the skeleton %s models' % skeleton_id) + raise ValueError('No neuron was found that the skeleton %s models' % skeleton_id) elif neuron_count > 1: - raise Exception('More than one neuron was found that the skeleton %s models' % skeleton_id) + raise ValueError('More than one neuron was found that the skeleton %s models' % skeleton_id) parent_neuron = neuron_rows[0] ancestry = [] @@ -1531,7 +1531,7 @@ def skeleton_ancestry(request:HttpRequest, project_id=None) -> JsonResponse: if parent_count == 0: break # We've reached the top of the hierarchy. elif parent_count > 1: - raise Exception('More than one class_instance was found that the class_instance %s is part_of.' % current_ci) + raise ValueError('More than one class_instance was found that the class_instance %s is part_of.' % current_ci) else: parent = parents[0] ancestry.append({ @@ -2074,14 +2074,14 @@ def reroot_skeleton(request:HttpRequest, project_id=None) -> JsonResponse: # Else, already root return JsonResponse({'error': 'Node #%s is already root!' % treenode_id}) except Exception as e: - raise Exception(response_on_error + ':' + str(e)) + raise ValueError(response_on_error + ':' + str(e)) def _reroot_skeleton(treenode_id, project_id): """ Returns the treenode instance that is now root, or False if the treenode was root already. """ if treenode_id is None: - raise Exception('A treenode id has not been provided!') + raise ValueError('A treenode id has not been provided!') response_on_error = '' try: @@ -2092,7 +2092,7 @@ def _reroot_skeleton(treenode_id, project_id): n_samplers = Sampler.objects.filter(skeleton=rootnode.skeleton).count() response_on_error = 'Neuron is used in a sampler, can\'t reroot' if n_samplers > 0: - raise Exception(f'Skeleton {rootnode.skeleton_id} is used in {n_samplers} sampler(s)') + raise ValueError(f'Skeleton {rootnode.skeleton_id} is used in {n_samplers} sampler(s)') # Obtain the treenode from the response first_parent = rootnode.parent_id @@ -2145,7 +2145,7 @@ def _reroot_skeleton(treenode_id, project_id): return rootnode except Exception as e: - raise Exception(f'{response_on_error}: {e}') + raise ValueError(f'{response_on_error}: {e}') def _root_as_parent(oid): @@ -2199,7 +2199,7 @@ def join_skeleton(request:HttpRequest, project_id=None) -> JsonResponse: }) except Exception as e: - raise Exception(response_on_error + ':' + str(e)) + raise ValueError(response_on_error + ':' + str(e)) def make_annotation_map(annotation_vs_user_id, neuron_id, cursor=None) -> Dict: @@ -2253,7 +2253,7 @@ def _join_skeleton(user, from_treenode_id, to_treenode_id, project_id, annotation that references the merged in skeleton using its name. """ if from_treenode_id is None or to_treenode_id is None: - raise Exception('Missing arguments to _join_skeleton') + raise ValueError('Missing arguments to _join_skeleton') response_on_error = '' try: @@ -2263,12 +2263,12 @@ def _join_skeleton(user, from_treenode_id, to_treenode_id, project_id, try: from_treenode = Treenode.objects.get(pk=from_treenode_id) except Treenode.DoesNotExist: - raise Exception("Could not find a skeleton for treenode #%s" % from_treenode_id) + raise ValueError("Could not find a skeleton for treenode #%s" % from_treenode_id) try: to_treenode = Treenode.objects.get(pk=to_treenode_id) except Treenode.DoesNotExist: - raise Exception("Could not find a skeleton for treenode #%s" % to_treenode_id) + raise ValueError("Could not find a skeleton for treenode #%s" % to_treenode_id) from_skid = from_treenode.skeleton_id from_neuron = _get_neuronname_from_skeletonid( project_id, from_skid ) @@ -2277,7 +2277,7 @@ def _join_skeleton(user, from_treenode_id, to_treenode_id, project_id, to_neuron = _get_neuronname_from_skeletonid( project_id, to_skid ) if from_skid == to_skid: - raise Exception('Cannot join treenodes of the same skeleton, this would introduce a loop.') + raise ValueError('Cannot join treenodes of the same skeleton, this would introduce a loop.') # Make sure the user has permissions to edit both neurons can_edit_class_instance_or_fail( @@ -2342,7 +2342,7 @@ def merge_into_annotation_map(source, skid, target): if not check_annotations_on_join(project_id, user, from_neuron['neuronid'], to_neuron['neuronid'], frozenset(annotation_map.keys())): - raise Exception("Annotation distribution is not valid for joining. " \ + raise ValueError("Annotation distribution is not valid for joining. " \ "Annotations for which you don't have permissions have to be kept!") # Find oldest creation_time and edition_time @@ -2436,7 +2436,7 @@ def merge_into_annotation_map(source, skid, target): return response except Exception as e: - raise Exception(response_on_error + ':' + str(e)) + raise ValueError(response_on_error + ':' + str(e)) def _update_samplers_in_merge(project_id, user_id, win_skeleton_id, lose_skeleton_id, @@ -2996,7 +2996,7 @@ def relate_neuron_to_skeleton(neuron, skeleton): # treenodes. root = find_root(arborescence) if root is None: - raise Exception('No root, provided graph is malformed!') + raise ValueError('No root, provided graph is malformed!') # Bulk create the required number of treenodes. This must be done in two # steps because treenode IDs are not known. diff --git a/django/applications/catmaid/control/textlabel.py b/django/applications/catmaid/control/textlabel.py index 59415d265b..db9639616a 100644 --- a/django/applications/catmaid/control/textlabel.py +++ b/django/applications/catmaid/control/textlabel.py @@ -51,7 +51,7 @@ def update_textlabel(request:HttpRequest, project_id=None) -> HttpResponse: return HttpResponse(' ') except Exception as e: - raise Exception(response_on_error + ':' + str(e)) + raise ValueError(response_on_error + ':' + str(e)) @requires_user_role(UserRole.Annotate) diff --git a/django/applications/catmaid/control/transaction.py b/django/applications/catmaid/control/transaction.py index 1976bf8ed9..802723a8b5 100644 --- a/django/applications/catmaid/control/transaction.py +++ b/django/applications/catmaid/control/transaction.py @@ -3,6 +3,7 @@ from typing import Dict from django.db import connection +from catmaid.error import ClientError from catmaid.control.authentication import requires_user_role from catmaid.models import UserRole @@ -11,7 +12,7 @@ from rest_framework.response import Response -class LocationLookupError(Exception): +class LocationLookupError(ClientError): pass diff --git a/django/applications/catmaid/control/treenode.py b/django/applications/catmaid/control/treenode.py index 88e412d3ed..bd32147de0 100644 --- a/django/applications/catmaid/control/treenode.py +++ b/django/applications/catmaid/control/treenode.py @@ -422,7 +422,7 @@ def relate_neuron_to_skeleton(neuron, skeleton): except Exception as e: import traceback - raise Exception("%s: %s %s" % (response_on_error, str(e), + raise ValueError("%s: %s %s" % (response_on_error, str(e), str(traceback.format_exc()))) @@ -441,7 +441,7 @@ def update_parent(request:HttpRequest, project_id=None, treenode_id=None) -> Jso parent = get_object_or_404(Treenode, pk=parent_id, project_id=project_id) if child.skeleton_id != parent.skeleton_id: - raise Exception("Child node %s is in skeleton %s but parent node %s is in skeleton %s!", \ + raise ValueError("Child node %s is in skeleton %s but parent node %s is in skeleton %s!", \ treenode_id, child.skeleton_id, parent_id, parent.skeleton_id) child.parent_id = parent_id @@ -531,7 +531,7 @@ def update_radius(request:HttpRequest, project_id=None, treenode_id=None) -> Jso treenode_id = int(treenode_id) radius = float(request.POST.get('radius', -1)) if math.isnan(radius): - raise Exception("Radius '%s' is not a number!" % request.POST.get('radius')) + raise ValueError("Radius '%s' is not a number!" % request.POST.get('radius')) option = int(request.POST.get('option', 0)) cursor = connection.cursor() # Make sure the back-end is in the expected state @@ -697,7 +697,7 @@ def delete_treenode(request:HttpRequest, project_id=None) -> JsonResponse: if n_children > 0: # TODO yes you can, the new root is the first of the children, # and other children become independent skeletons - raise Exception("You can't delete the root node when it " + raise ValueError("You can't delete the root node when it " "has children.") # Get the neuron before the skeleton is deleted. It can't be # accessed otherwise anymore. @@ -768,7 +768,7 @@ def delete_treenode(request:HttpRequest, project_id=None) -> JsonResponse: }) except Exception as e: - raise Exception(response_on_error + ': ' + str(e)) + raise ValueError(response_on_error + ': ' + str(e)) def _compact_detail_list(project_id, treenode_ids=None, label_ids=None, label_names=None, skeleton_ids=None): @@ -1020,7 +1020,7 @@ def find_children(request:HttpRequest, project_id=None, treenode_id=None) -> Jso children = [[row] for row in cursor.fetchall()] return JsonResponse(children, safe=False) except Exception as e: - raise Exception('Could not obtain next branch node or leaf: ' + str(e)) + raise ValueError('Could not obtain next branch node or leaf: ' + str(e)) @api_view(['POST']) @@ -1176,7 +1176,7 @@ def _find_first_interesting_node(sequence): Otherwise return the last node. """ if not sequence: - raise Exception('No nodes ahead!') + raise ValueError('No nodes ahead!') if 1 == len(sequence): return sequence[0] @@ -1201,7 +1201,7 @@ def _find_first_interesting_node(sequence): if props[1] < 5 or props[2] or props[3]: return node_id else: - raise Exception('Nodes of this skeleton changed while inspecting them.') + raise ValueError('Nodes of this skeleton changed while inspecting them.') return sequence[-1] @@ -1231,7 +1231,7 @@ def find_previous_branchnode_or_root(request:HttpRequest, project_id=None, treen return JsonResponse(_fetch_location(project_id, tnid), safe=False) except Exception as e: - raise Exception('Could not obtain previous branch node or root:' + str(e)) + raise ValueError('Could not obtain previous branch node or root:' + str(e)) @requires_user_role([UserRole.Annotate, UserRole.Browse]) @@ -1275,4 +1275,4 @@ def find_next_branchnode_or_end(request:HttpRequest, project_id=None, treenode_i branches = [[node_locations[node_id] for node_id in branch] for branch in branches] return JsonResponse(branches, safe=False) except Exception as e: - raise Exception('Could not obtain next branch node or leaf: ' + str(e)) + raise ValueError('Could not obtain next branch node or leaf: ' + str(e)) diff --git a/django/applications/catmaid/control/user.py b/django/applications/catmaid/control/user.py index dc6802fa9d..088c45d359 100644 --- a/django/applications/catmaid/control/user.py +++ b/django/applications/catmaid/control/user.py @@ -12,7 +12,7 @@ from django.contrib.auth.models import User import django.contrib.auth.views as django_auth_views -from catmaid.control.authentication import access_check +from catmaid.control.authentication import (access_check, PermissionError) from catmaid.control.common import get_request_bool diff --git a/django/applications/catmaid/error.py b/django/applications/catmaid/error.py new file mode 100644 index 0000000000..9e4c7380c7 --- /dev/null +++ b/django/applications/catmaid/error.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + + +class ClientError(ValueError): + """Client errors are the result of bad values or requests by the client. In + the general case this will result in a status 400 error. + """ + status_code = 400 diff --git a/django/applications/catmaid/middleware.py b/django/applications/catmaid/middleware.py index d2bf2c9a3f..d387c1d619 100644 --- a/django/applications/catmaid/middleware.py +++ b/django/applications/catmaid/middleware.py @@ -7,12 +7,15 @@ from traceback import format_exc from datetime import datetime -from django.http import JsonResponse +from django.http import JsonResponse, Http404 from django.conf import settings from guardian.utils import get_anonymous_user from rest_framework.authentication import TokenAuthentication +from rest_framework.exceptions import APIException + +from catmaid.error import ClientError from io import StringIO @@ -104,7 +107,21 @@ def process_exception(self, request, exception): (exc_type, exc_info, tb) = sys.exc_info() response['info'] = str(exc_info) response['traceback'] = ''.join(traceback.format_tb(tb)) - return JsonResponse(response) + + # Some CATMAID errors have a more detailed status code + if isinstance(exception, ClientError): + status = exception.status_code + elif isinstance(exception, Http404): + status = 404 + elif isinstance(exception, APIException): + status = exception.status_code + elif isinstance(exception, ValueError): + # Value errors are assumed to be problems with the request/client. + status = 400 + else: + status = 500 + + return JsonResponse(response, status=status, safe=False) class BasicModelMapMiddleware(object): diff --git a/django/applications/catmaid/state.py b/django/applications/catmaid/state.py index 65033de6ac..9c2b253905 100644 --- a/django/applications/catmaid/state.py +++ b/django/applications/catmaid/state.py @@ -7,8 +7,10 @@ from django.db import connection +from catmaid.error import ClientError -class StateMatchingError(Exception): + +class StateMatchingError(ClientError): """Indicates that a state check wasn't successful""" def __init__(self, message, state): super().__init__(message) diff --git a/django/applications/catmaid/static/js/CATMAID.js b/django/applications/catmaid/static/js/CATMAID.js index 31361071a8..ca30247974 100644 --- a/django/applications/catmaid/static/js/CATMAID.js +++ b/django/applications/catmaid/static/js/CATMAID.js @@ -184,49 +184,6 @@ */ CATMAID.warn = CATMAID.msg.bind(window, "Warning"); - /** - * Creates a generic JSON response handler that complains when the response - * status is different from 200 or a JSON error is set. - * - * @param success Called on success - * @param error Called on error - * @param silent No error dialogs are shown, if true - */ - CATMAID.jsonResponseHandler = function(success, error, silent) - { - return function(status, text, xml) { - if (status >= 200 && status <= 204 && - (typeof text === 'string' || text instanceof String)) { - // `text` may be empty for no content responses. - var json = text.length ? JSON.parse(text) : {}; - if (json.error) { - // Call error handler, if any, and force silence if it returned true. - if (CATMAID.tools.isFn(error)) { - silent = error(json) || silent; - } - if (!silent) { - CATMAID.error(json.error, json.detail); - } - } else { - CATMAID.tools.callIfFn(success, json); - } - } else { - var e = { - error: "An error occured", - detail: "The server returned an unexpected status: " + status, - status: status - }; - // Call error handler, if any, and force silence if it returned true. - if (CATMAID.tools.isFn(error)) { - silent = error(e) || silent; - } - if (!silent) { - CATMAID.error(e.msg, e.detail); - } - } - }; - }; - /** * Convenience function to show an error dialog. */ diff --git a/django/applications/catmaid/static/js/widgets/compartment_graph_widget.js b/django/applications/catmaid/static/js/widgets/compartment_graph_widget.js index 5a21a9a659..8a78e22a8f 100644 --- a/django/applications/catmaid/static/js/widgets/compartment_graph_widget.js +++ b/django/applications/catmaid/static/js/widgets/compartment_graph_widget.js @@ -2271,28 +2271,31 @@ accum = $.extend({}, s.split_partners); // TODO unused? var grow = function(skids, n_circles, callback) { - requestQueue.register(CATMAID.makeURL(project.id + "/graph/circlesofhell"), - "POST", - {skeleton_ids: skids, - n_circles: n_circles, - min_pre: p.min_upstream, - min_post: p.min_downstream}, - CATMAID.jsonResponseHandler(function(json) { - if (p.filter_regex !== '') { - requestQueue.register(CATMAID.makeURL(project.id + "/annotations/forskeletons"), - "POST", - {skeleton_ids: json[0]}, - CATMAID.jsonResponseHandler(function (json) { - var filterRegex = new RegExp(p.filter_regex, 'i'); - var filteredNeighbors = Object.keys(json.skeletons).filter(function (skid) { - return json.skeletons[skid].some(function (a) { - return filterRegex.test(json.annotations[a.id]); - }); - }); - callback(skids.concat(filteredNeighbors)); - })); - } else callback(skids.concat(json[0])); - })); + CATMAID.fetch(project.id + "/graph/circlesofhell", "POST", { + skeleton_ids: skids, + n_circles: n_circles, + min_pre: p.min_upstream, + min_post: p.min_downstream, + }) + .then(json => { + if (p.filter_regex !== '') { + return CATMAID.fetch(project.id + "/annotations/forskeletons", "POST", { + skeleton_ids: json[0], + }) + .then(annotations => { + var filterRegex = new RegExp(p.filter_regex, 'i'); + var filteredNeighbors = Object.keys(annotations.skeletons).filter(function (skid) { + return annotations.skeletons[skid].some(function (a) { + return filterRegex.test(annotations.annotations[a.id]); + }); + }); + callback(skids.concat(filteredNeighbors)); + }); + } else { + callback(skids.concat(json[0])); + } + }) + .catch(CATMAID.handleError); }, append = (function(skids) { var color = new THREE.Color().setHex(0xffae56), diff --git a/django/applications/catmaid/static/js/widgets/overlay.js b/django/applications/catmaid/static/js/widgets/overlay.js index fdc6da4403..3fdfd0fa3c 100644 --- a/django/applications/catmaid/static/js/widgets/overlay.js +++ b/django/applications/catmaid/static/js/widgets/overlay.js @@ -4062,7 +4062,7 @@ var SkeletonAnnotations = {}; } return Promise.all(requests); - }) + }, e => true /* Tell submitter we handle error */) .then(function(responses) { // Bail if the overlay was destroyed or suspended before this callback. if (self.suspended) { @@ -4143,7 +4143,8 @@ var SkeletonAnnotations = {}; // Return a proper promise to the caller (i.e. no submitter instance). return work.promise(); - }); + }) + .catch(CATMAID.handleError); }; CATMAID.TracingOverlay.prototype.createSubViewNodeListFromCache = function(params) { diff --git a/django/applications/catmaid/static/libs/catmaid/CATMAID.js b/django/applications/catmaid/static/libs/catmaid/CATMAID.js index c3796d3df0..c4f140f1dc 100644 --- a/django/applications/catmaid/static/libs/catmaid/CATMAID.js +++ b/django/applications/catmaid/static/libs/catmaid/CATMAID.js @@ -317,15 +317,8 @@ var requestQueue = new CATMAID.RequestQueue(); return text; } else if (additionalStatusCodes && additionalStatusCodes.indexOf(status) > -1) { return text; - } else if (status === 502) { // Bad Gateway - var error = new CATMAID.NetworkAccessError("CATMAID server unreachable", - "Please wait or try to reload"); - error.statusCode = status; - throw error; } else { - var error = new CATMAID.Error("The server returned an unexpected status: " + status); - error.statusCode = status; - throw error; + throw new CATMAID.Error("The server returned an unexpected status: " + status); } }; @@ -338,19 +331,14 @@ var requestQueue = new CATMAID.RequestQueue(); CATMAID.validateJsonResponse = function(status, text, xml, additionalStatusCodes) { var response = CATMAID.validateResponse(status, text, xml, undefined, additionalStatusCodes); // `text` may be empty for no content responses. - var json = text.length ? JSON.parse(text) : {}; - if (json.error) { - var error = CATMAID.parseErrorResponse(json); - throw error; - } else { - return json; - } + return text.length ? JSON.parse(text) : {}; }; /** * Translate an error response into the appropriate front-end type. */ - CATMAID.parseErrorResponse = function(error) { + CATMAID.parseErrorResponse = function(error, statusCode = undefined) { + error = error | {}; if ('ValueError' === error.type) { return new CATMAID.ValueError(error.error, error.detail); } else if ('StateMatchingError' === error.type) { @@ -365,6 +353,10 @@ var requestQueue = new CATMAID.RequestQueue(); return new CATMAID.InactiveLoginError(error.error, error.detail, error.meta); } else if ('ReplacedRequestError' === error.type) { return new CATMAID.ReplacedRequestError(error.error, error.detail); + } else if (statusCode === 404) { + return new CATMAID.MissingResourceError(error.error, error.detail); + } else if (statusCode === 403) { + return new CATMAID.PermissionError("Insufficient permissions", error); } else { return new CATMAID.Error("Unsuccessful request: " + error.error, error.detail, error.type); @@ -450,6 +442,22 @@ var requestQueue = new CATMAID.RequestQueue(); // this wasn't an asynchronously called function. But since this is the // case, we have to call reject() explicitly. try { + // Handle client and server errors + if (status >= 400 && status < 600) { + if (status === 502) { // Bad Gateway + var error = new CATMAID.NetworkAccessError("CATMAID server unreachable", + "Please wait or try to reload"); + throw error; + } + let errorDetails; + try { + errorDetails = JSON.parse(text); + } catch (e) { + errorDetails = null; + } + throw CATMAID.parseErrorResponse(errorDetails, status); + } + if (raw) { var response = CATMAID.validateResponse(status, text, xml, responseType, supportedStatus); diff --git a/django/applications/catmaid/static/libs/catmaid/datastores.js b/django/applications/catmaid/static/libs/catmaid/datastores.js index 4de9d6912b..490c66d235 100644 --- a/django/applications/catmaid/static/libs/catmaid/datastores.js +++ b/django/applications/catmaid/static/libs/catmaid/datastores.js @@ -44,59 +44,56 @@ * @return {Promise} Promise resolving once the datastore values are loaded. */ DataStore.prototype.load = function () { - var self = this; - return new Promise(function (resolve, reject) { - requestQueue.register( - CATMAID.makeURL('/client/datastores/' + self.name + '/'), - 'GET', - {project_id: project ? project.id : undefined}, - CATMAID.jsonResponseHandler( - function (data) { - self.entries = data.reduce( - function (e, d) { - if (d.project) { - var scope = d.user ? 'USER_PROJECT' : 'PROJECT_DEFAULT'; - } else { - var scope = d.user ? 'USER_DEFAULT' : 'GLOBAL'; - } - - if (!e.has(d.key)) { - e.set(d.key, {}); - } - - try { - var value = (typeof d.value === 'string' || d.value instanceof String) ? - JSON.parse(d.value) : - d.value; - e.get(d.key)[scope] = { - dirty: false, - value: value - }; - } catch (error) { - // Do not alert the user, since this will not affect - // other key/scopes and there is nothing explicit they - // can do to correct it. - console.log('Client data for store ' + d.key + - ', scope ' + scope + ' was not parsable.'); - } - - return e; - }, - new Map()); - self.trigger(DataStore.EVENT_LOADED, self); - resolve(); - }, - function (error) { - if (error.status === 404 || error.type === 'PermissionError') { - self.entries = new Map(); - self.trigger(DataStore.EVENT_LOADED, self); - resolve(); - return true; - } else { - reject(); - } - })); - }); + return CATMAID.fetch({ + url: `/client/datastores/${this.name}/`, + method: 'GET', + data: { + project_id: project ? project.id : undefined + }, + }) + .then(data => { + this.entries = data.reduce( + function (e, d) { + if (d.project) { + var scope = d.user ? 'USER_PROJECT' : 'PROJECT_DEFAULT'; + } else { + var scope = d.user ? 'USER_DEFAULT' : 'GLOBAL'; + } + + if (!e.has(d.key)) { + e.set(d.key, {}); + } + + try { + var value = (typeof d.value === 'string' || d.value instanceof String) ? + JSON.parse(d.value) : + d.value; + e.get(d.key)[scope] = { + dirty: false, + value: value + }; + } catch (error) { + // Do not alert the user, since this will not affect + // other key/scopes and there is nothing explicit they + // can do to correct it. + console.log('Client data for store ' + d.key + + ', scope ' + scope + ' was not parsable.'); + } + + return e; + }, + new Map()); + this.trigger(DataStore.EVENT_LOADED, this); + }) + .catch(error => { + if (error instanceof CATMAID.MissingResourceError || + error instanceof CATMAID.PermissionError) { + this.entries = new Map(); + this.trigger(DataStore.EVENT_LOADED, this); + return true; + } + return Promise.reject(error); + }); }; /** @@ -161,27 +158,25 @@ DataStore.prototype._store = function (key, scope) { var entry = this.entries.get(key)[scope]; entry.dirty = false; - var self = this; - return new Promise(function (resolve, reject) { - requestQueue.register( - CATMAID.makeURL('/client/datastores/' + self.name + '/'), - 'PUT', - { - project_id: (scope === 'USER_DEFAULT' || - scope === 'GLOBAL') ? - undefined : project.id, - ignore_user: scope === 'PROJECT_DEFAULT' || - scope === 'GLOBAL', - key: key, - value: JSON.stringify(entry.value) - }, - CATMAID.jsonResponseHandler(resolve, reject, true)); - }).catch(function (reason) { - if (reason && reason.status && reason.status === 403) { - console.log('Datastore lacks permissions to store for ' + - 'store: ' + self.name + ' key: ' + key + ' scope: ' + scope); - } - }); + return CATMAID.fetch({ + url: `/client/datastores/${this.name}/`, + method: 'PUT', + data: { + project_id: (scope === 'USER_DEFAULT' || + scope === 'GLOBAL') ? + undefined : project.id, + ignore_user: scope === 'PROJECT_DEFAULT' || + scope === 'GLOBAL', + key: key, + value: JSON.stringify(entry.value) + }, + }) + .catch(reason => { + if (reason && reason.status && reason.status === 403) { + console.log('Datastore lacks permissions to store for ' + + `store: ${this.name} key: ${key} scope: ${scope}`); + } + }); }; /** diff --git a/django/applications/catmaid/static/libs/catmaid/error.js b/django/applications/catmaid/static/libs/catmaid/error.js index 8234dac299..44c6b0b83f 100644 --- a/django/applications/catmaid/static/libs/catmaid/error.js +++ b/django/applications/catmaid/static/libs/catmaid/error.js @@ -143,4 +143,9 @@ */ CATMAID.ReplacedRequestError = class ReplacedRequestError extends CATMAID.Error {}; + /** + * Indicate a missing (remote) resource. + */ + CATMAID.MissingResourceError = class MissingResourceError extends CATMAID.Error {}; + })(CATMAID); diff --git a/django/applications/catmaid/static/libs/catmaid/settings-manager.js b/django/applications/catmaid/static/libs/catmaid/settings-manager.js index 6c6a644988..0f531048aa 100644 --- a/django/applications/catmaid/static/libs/catmaid/settings-manager.js +++ b/django/applications/catmaid/static/libs/catmaid/settings-manager.js @@ -94,12 +94,13 @@ * group and optional migrations between settings * version. See JSDoc for more. */ - function Settings(name, schema) { + function Settings(name, schema, anonymousWriteBack = false) { this.name = name; this.schema = schema; + this.anonymousWriteBack = anonymousWriteBack; this.rendered = {}; this.settingsStore = CATMAID.DataStoreManager.get(Settings.DATA_STORE_NAME); - this._boundLoad = this.load.bind(this); + this._boundLoad = this.load.bind(this, undefined); this._storeThrottleTimeout = null; this.settingsStore.on(CATMAID.DataStore.EVENT_LOADED, this._boundLoad); this.load(); @@ -128,9 +129,16 @@ * Load settings values for all scopes by retrieving persisted values from * the DataStore and cascading values across scopes. * + * @param {Boolean} anonymousWriteBack (optional) Whether to write changed or + * migrated settings back to the server if + * the current user is not logged in. + * * @return {Promise} Promise yielding once loading is complete. */ - Settings.prototype.load = function () { + Settings.prototype.load = function (anonymousWriteBack = undefined) { + if (anonymousWriteBack === undefined) { + anonymousWriteBack = this.anonymousWriteBack; + } var self = this; return this.settingsStore.get(this.name).then(function (stored) { var rendered = Object.keys(self.schema.entries).reduce(function (r, k) { @@ -143,6 +151,8 @@ return r; }, {}); + let work = []; + // For scope level, in order of increasing specificity, check // persisted settings, migrate them if necesssary, merge them if // possible, then create an object allowing direct access and @@ -160,7 +170,8 @@ ' settings, resetting to defaults.'); scopeValues = {version: self.schema.version, entries: {}}; } - self.settingsStore.set(self.name, scopeValues, datastoreScope, true); + let writeThrough = anonymousWriteBack || (!!CATMAID.session && CATMAID.session.is_authenticated); + work.push(self.settingsStore.set(self.name, scopeValues, datastoreScope, writeThrough)); } Object.keys(scopeValues.entries).forEach(function (k) { @@ -198,6 +209,8 @@ self.rendered[scope] = $.extend(true, {}, rendered); }); + + return Promise.all(work); }); }; diff --git a/django/applications/catmaid/tests/apis/test_datastores.py b/django/applications/catmaid/tests/apis/test_datastores.py index 07ac407541..e2b50caa71 100644 --- a/django/applications/catmaid/tests/apis/test_datastores.py +++ b/django/applications/catmaid/tests/apis/test_datastores.py @@ -9,14 +9,14 @@ class DatastoresApiTests(CatmaidApiTestCase): def test_client_datastores(self): url = '/client/datastores/' response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertIn('error', parsed_response) self.assertIn('type', parsed_response) self.assertEquals('PermissionError', parsed_response['type']) response = self.client.post(url) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertIn('error', parsed_response) self.assertIn('type', parsed_response) @@ -26,7 +26,7 @@ def test_client_datastores(self): self.fake_authentication() name = 'test- %% datastore' response = self.client.post(url, {'name': name}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 500) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) name = 'test-datastore' diff --git a/django/applications/catmaid/tests/apis/test_links.py b/django/applications/catmaid/tests/apis/test_links.py index d779a9e5d5..61613b1642 100644 --- a/django/applications/catmaid/tests/apis/test_links.py +++ b/django/applications/catmaid/tests/apis/test_links.py @@ -19,7 +19,7 @@ def test_delete_link_failure(self): '/%d/link/delete' % self.test_project_id, {'connector_id': connector_id, 'treenode_id': treenode_id, 'state': make_nocheck_state()}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = {'error': f'Could not find link between connector {connector_id} and node {treenode_id}'} self.assertIn('error', parsed_response) diff --git a/django/applications/catmaid/tests/apis/test_messages.py b/django/applications/catmaid/tests/apis/test_messages.py index 6d99961627..6fcc5cfaf7 100644 --- a/django/applications/catmaid/tests/apis/test_messages.py +++ b/django/applications/catmaid/tests/apis/test_messages.py @@ -13,7 +13,7 @@ def test_read_message_error(self): message_id = 5050 response = self.client.post(f'/messages/{message_id}/mark_read') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 404) parsed_response = json.loads(response.content.decode('utf-8')) self.assertIn('error', parsed_response) self.assertIn('type', parsed_response) diff --git a/django/applications/catmaid/tests/apis/test_neurons.py b/django/applications/catmaid/tests/apis/test_neurons.py index adf00106bc..bfab324a64 100644 --- a/django/applications/catmaid/tests/apis/test_neurons.py +++ b/django/applications/catmaid/tests/apis/test_neurons.py @@ -50,7 +50,7 @@ def test_rename_neuron_fail(self): url = '/%d/neurons/%s/rename' % (self.test_project_id, neuron_id) response = self.client.post(url, {'name': new_name}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) self.assertTrue(parsed_response['error']) diff --git a/django/applications/catmaid/tests/apis/test_nodes.py b/django/applications/catmaid/tests/apis/test_nodes.py index 478bb53785..ee0cbe5fec 100644 --- a/django/applications/catmaid/tests/apis/test_nodes.py +++ b/django/applications/catmaid/tests/apis/test_nodes.py @@ -157,7 +157,7 @@ def test_node_get_locations_wrong_project(self): response = self.client.post( '/%d/nodes/location' % (self.test_project_id + 1), { 'node_ids': treenode_ids }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) @@ -246,7 +246,7 @@ def test_node_update_invalid_location(self): 't[0][1]': x, 't[0][2]': y, 't[0][3]': z}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) self.assertIn('error', parsed_response) cursor = connection.cursor() @@ -351,7 +351,7 @@ def insert_params(dictionary, param_id, params): response = self.client.post( '/%d/node/update' % self.test_project_id, param_dict) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = {'error': 'User test2 cannot edit all of the 4 ' 'unique objects from table treenode'} diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index 1643ce57ea..bc19f75fef 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -111,7 +111,7 @@ def test_import_skeleton(self): {'file.swc': swc_file2, 'name': 'test2', 'neuron_id': neuron.id, 'auto_id': False}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = { "error": "The passed in neuron ID is already in use and neither of the parameters force or auto_id are set to true."} @@ -185,7 +185,7 @@ def test_import_skeleton(self): {'file.swc': swc_file2, 'name': 'test2', 'skeleton_id': skeleton.id, 'auto_id': False}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = { "error": "The passed in skeleton ID is already in use and neither of the parameters force or auto_id are set to true."} @@ -365,7 +365,7 @@ def test_split_skeleton_annotations(self): {'treenode_id': 2394, 'upstream_annotation_map': json.dumps({'A': self.test_user_id}), 'downstream_annotation_map': json.dumps({'C': self.test_user_id})}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = "Annotation distribution is not valid for splitting. " \ "One part has to keep the whole set of annotations!" @@ -378,7 +378,7 @@ def test_split_skeleton_annotations(self): {'treenode_id': 2394, 'upstream_annotation_map': json.dumps({'A': self.test_user_id, 'B': self.test_user_id}), 'downstream_annotation_map': json.dumps({'C': self.test_user_id, 'B': self.test_user_id})}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = "Annotation distribution is not valid for splitting. " \ "One part has to keep the whole set of annotations!" @@ -813,7 +813,7 @@ def test_skeleton_graph(self): # Confidence split # Change confidence that affects 1 edge from 235 to 373 response = self.client.post('/%d/treenodes/289/confidence' % self.test_project_id, - {'new_confidence': 3}) + {'new_confidence': 3, 'state': '{"nocheck": true}'}) self.assertEqual(response.status_code, 200) # Add confidence criteria, but not one that should affect the graph. response = self.client.post( @@ -841,10 +841,11 @@ def test_skeleton_graph(self): 'skeleton_ids[2]': skeleton_ids[2], 'confidence_threshold': 4}) parsed_response = json.loads(response.content.decode('utf-8')) - expected_result_nodes = frozenset(['235', '361', '373']) + expected_result_nodes = frozenset(['235_1', '235_2', '361', '373']) expected_result_edges = [ - ['235', '373', [0, 0, 0, 0, 2]], - ['235', '361', [0, 0, 0, 0, 1]]] + ['235_1', '373', [0, 0, 0, 0, 1]], + ['235_2', '373', [0, 0, 0, 0, 1]], + ['235_2', '361', [0, 0, 0, 0, 1]]] self.assertEqual(expected_result_nodes, frozenset(parsed_response['nodes'])) # Since order is not important, check length and matches separately. @@ -864,10 +865,11 @@ def test_skeleton_graph(self): 'confidence_threshold': 4, 'bandwidth': 2000}) parsed_response = json.loads(response.content.decode('utf-8')) - expected_result_nodes = frozenset(['361', '373', '235_1', '235_2']) + expected_result_nodes = frozenset(['361', '373', '235_1', '235_2_2', '235_2_3']) expected_result_edges = [ - ['235_1', '373', [0, 0, 0, 0, 2]], - ['235_1', '361', [0, 0, 0, 0, 1]]] + ['235_1', '373', [0, 0, 0, 0, 1]], + ['235_2_2', '373', [0, 0, 0, 0, 1]], + ['235_2_2', '361', [0, 0, 0, 0, 1]]] self.assertEqual(expected_result_nodes, frozenset(parsed_response['nodes'])) # Since order is not important, check length and matches separately. @@ -896,7 +898,8 @@ def test_skeleton_graph(self): 'confidence_threshold': 4}) parsed_response = json.loads(response.content.decode('utf-8')) expected_result_edges = [ - ['235', '373', [0, 0, 0, 0, 2]]] + ['235_1', '373', [0, 0, 0, 0, 1]], + ['235_2', '373', [0, 0, 0, 0, 1]]] # Since order is not important, check length and matches separately. self.assertEqual(len(expected_result_edges), len(parsed_response['edges'])) for row in expected_result_edges: diff --git a/django/applications/catmaid/tests/apis/test_textlabels.py b/django/applications/catmaid/tests/apis/test_textlabels.py index f54cb25ff0..bf13b60477 100644 --- a/django/applications/catmaid/tests/apis/test_textlabels.py +++ b/django/applications/catmaid/tests/apis/test_textlabels.py @@ -101,7 +101,7 @@ def test_update_textlabel_failure(self): response = self.client.post( '/%d/textlabel/update' % self.test_project_id, params) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = 'Failed to find Textlabel with id %s.' % textlabel_id self.assertIn('error', parsed_response) diff --git a/django/applications/catmaid/tests/apis/test_treenodes.py b/django/applications/catmaid/tests/apis/test_treenodes.py index 4fe3d4af3d..329646486d 100644 --- a/django/applications/catmaid/tests/apis/test_treenodes.py +++ b/django/applications/catmaid/tests/apis/test_treenodes.py @@ -32,7 +32,7 @@ def test_fail_update_confidence(self): response = self.client.post( '/%d/treenodes/%d/confidence' % (self.test_project_id, treenode_id), {'new_confidence': '4'}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) expected_result = 'No skeleton and neuron for treenode %s' % treenode_id parsed_response = json.loads(response.content.decode('utf-8')) self.assertEqual(expected_result, parsed_response['error']) @@ -288,7 +288,7 @@ def test_create_treenode_with_nonexisting_parent_failure(self): 'parent_id': parent_id, 'radius': 2, 'state': make_nocheck_state()}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = {'error': 'Parent treenode %d does not exist' % parent_id} self.assertIn(expected_result['error'], parsed_response['error']) @@ -335,7 +335,7 @@ def test_delete_root_treenode_with_children_failure(self): response = self.client.post( '/%d/treenode/delete' % self.test_project_id, {'treenode_id': treenode_id, 'state': make_nocheck_state()}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = "Could not delete root node: You can't delete the " \ "root node when it has children." @@ -497,7 +497,7 @@ def test_insert_treenoded_not_on_edge_without_permission(self): 'child_id': child_id, 'parent_id': parent_id}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) @@ -539,7 +539,7 @@ def test_insert_treenoded_no_child_parent(self): 'child_id': child_id, 'parent_id': parent_id}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) @@ -635,7 +635,7 @@ def test_treenode_info_nonexisting_treenode_failure(self): response = self.client.get( '/%d/treenodes/%s/info' % (self.test_project_id, treenode_id)) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 400) parsed_response = json.loads(response.content.decode('utf-8')) expected_result = 'No skeleton and neuron for treenode %s' % treenode_id self.assertIn('error', parsed_response) diff --git a/django/applications/catmaid/tests/gui/test_basic_gui.py b/django/applications/catmaid/tests/gui/test_basic_gui.py index ad411151f6..32f1b49490 100644 --- a/django/applications/catmaid/tests/gui/test_basic_gui.py +++ b/django/applications/catmaid/tests/gui/test_basic_gui.py @@ -188,9 +188,14 @@ def test_home_page_login_logout(self): # Let test fail self.assertNotIn('SyntaxError', log_entry['message']) - # Fail on any severe errors + # Fail on any severe errors that aren't expected. Expected are 403 + # errors for settings loading for the anonymous user if not linked + # to any project. self.assertIn('level', log_entry) - self.assertNotIn('SEVERE', log_entry['level'], log_entry['message']) + unexpected_error = 'SEVERE' in log_entry['level'] and \ + '403 (Forbidden)' not in log_entry['message'] + if unexpected_error: + self.assertNotIn('SEVERE', log_entry['level'], log_entry['message']) # Check title self.assertTrue("CATMAID" in self.selenium.title) diff --git a/django/applications/catmaid/tests/test_common_apis.py b/django/applications/catmaid/tests/test_common_apis.py index 409cae9442..015c95de8c 100644 --- a/django/applications/catmaid/tests/test_common_apis.py +++ b/django/applications/catmaid/tests/test_common_apis.py @@ -328,7 +328,7 @@ def test_user_list_with_passwords_regular_user(self): 'with_passwords': True, }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) parsed_response = json.loads(response.content.decode('utf-8')) self.assertTrue('error' in parsed_response) @@ -693,7 +693,7 @@ def test_can_browse_access(self): for api in self.can_browse_get_api: msg = "GET %s" % api response = self.client.get(api) - self.assertEqual(response.status_code, 200, msg) + self.assertNotIn(response.status_code, (401, 403), msg) try: parsed_response = json.loads(response.content.decode('utf-8')) missing_permissions = ('error' in parsed_response and @@ -709,7 +709,7 @@ def test_can_browse_access(self): for api in self.can_browse_post_api: msg = "POST %s" % api response = self.client.post(api) - self.assertEqual(response.status_code, 200, msg) + self.assertNotIn(response.status_code, (401, 403), msg) try: parsed_response = json.loads(response.content.decode('utf-8')) missing_permissions = ('error' in parsed_response and diff --git a/django/lib/custom_testrunner.py b/django/lib/custom_testrunner.py index bdd17fa998..726a5cbe15 100644 --- a/django/lib/custom_testrunner.py +++ b/django/lib/custom_testrunner.py @@ -2,8 +2,16 @@ from django.conf import settings from django.test.runner import DiscoverRunner +from pipeline.conf import settings as pipeline_settings class TestSuiteRunner(DiscoverRunner): + def __init__(self, *args, **kwargs): settings.TESTING_ENVIRONMENT = True super(TestSuiteRunner, self).__init__(*args, **kwargs) + + def setup_test_environment(self, **kwargs): + '''Override STATICFILES_STORAGE and pipeline DEBUG.''' + super().setup_test_environment(**kwargs) + settings.STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage' + pipeline_settings.DEBUG = True