diff --git a/components/tools/OmeroWeb/omeroweb/decorators.py b/components/tools/OmeroWeb/omeroweb/decorators.py index 3e4b1a5675a..7c7d202fc75 100644 --- a/components/tools/OmeroWeb/omeroweb/decorators.py +++ b/components/tools/OmeroWeb/omeroweb/decorators.py @@ -25,7 +25,8 @@ import logging import traceback -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect, \ + JsonResponse from django.http import HttpResponseForbidden, StreamingHttpResponse from django.conf import settings @@ -36,7 +37,6 @@ from django.template import RequestContext from django.core.cache import cache -from omeroweb.http import HttpJsonResponse from omeroweb.utils import reverse_with_params from omeroweb.connector import Connector from omero.gateway.utils import propertiesToDict @@ -558,7 +558,9 @@ def wrapper(request, *args, **kwargs): # allows us to return the dict as json (NB: BlitzGateway objects # don't serialize) if template is None or template == 'json': - return HttpJsonResponse(context) + # We still need to support non-dict data: + safe = type(context) is dict + return JsonResponse(context, safe=safe) else: # allow additional processing of context dict ctx.prepare_context(request, context, *args, **kwargs) diff --git a/components/tools/OmeroWeb/omeroweb/feedback/templates/403_csrf.html b/components/tools/OmeroWeb/omeroweb/feedback/templates/403_csrf.html deleted file mode 100644 index f44556a2304..00000000000 --- a/components/tools/OmeroWeb/omeroweb/feedback/templates/403_csrf.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base_error.html" %} -{% load i18n %} - -{% block content %} -

{% trans "Forbidden" %} (403)

-

{% trans "CSRF Error. You don't have permission to access this page on this server." %}

- -{% endblock %} \ No newline at end of file diff --git a/components/tools/OmeroWeb/omeroweb/feedback/views.py b/components/tools/OmeroWeb/omeroweb/feedback/views.py index b8728444480..795fbd2f597 100644 --- a/components/tools/OmeroWeb/omeroweb/feedback/views.py +++ b/components/tools/OmeroWeb/omeroweb/feedback/views.py @@ -35,9 +35,8 @@ from django.conf import settings from django.template import loader as template_loader -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.http import HttpResponseServerError, HttpResponseNotFound -from django.http import HttpResponseForbidden from django.template import RequestContext from django.views.defaults import page_not_found from django.core.urlresolvers import reverse @@ -131,12 +130,16 @@ def send_comment(request): ############################################################################## # handlers - def csrf_failure(request, reason=""): - logger.warn('csrf_failure: Forbidden') - t = template_loader.get_template("403_csrf.html") - c = RequestContext(request, {}) - return HttpResponseForbidden(t.render(c)) + """ + Always return Json response + since this is accepted by browser and API users + """ + error = ("CSRF Error. You need to include valid CSRF tokens for any" + " POST, PUT, PATCH or DELETE operations." + " You have to include CSRF token in the POST data or" + " add the token to the HTTP header.") + return JsonResponse({"message": error}, status=403) def handler500(request): diff --git a/components/tools/OmeroWeb/omeroweb/http.py b/components/tools/OmeroWeb/omeroweb/http.py index 92d386fa377..30b28fc0a95 100644 --- a/components/tools/OmeroWeb/omeroweb/http.py +++ b/components/tools/OmeroWeb/omeroweb/http.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # -# Copyright (C) 2011-2014 University of Dundee & Open Microscopy Environment. +# Copyright (C) 2011-2016 University of Dundee & Open Microscopy Environment. # All rights reserved. # # This program is free software: you can redistribute it and/or modify @@ -19,14 +19,13 @@ # along with this program. If not, see . # -import json - from django.http import HttpResponse, HttpResponseServerError class HttpJavascriptResponse(HttpResponse): def __init__(self, content): - HttpResponse.__init__(self, content, content_type="text/javascript") + HttpResponse.__init__(self, content, + content_type="application/javascript") class HttpJavascriptResponseServerError(HttpResponseServerError): @@ -35,12 +34,6 @@ def __init__(self, content): self, content, content_type="text/javascript") -class HttpJsonResponse(HttpResponse): - def __init__(self, content): - HttpResponse.__init__( - self, json.dumps(content), content_type="application/json") - - class HttpJPEGResponse(HttpResponse): def __init__(self, content): HttpResponse.__init__(self, content, content_type="image/jpeg") diff --git a/components/tools/OmeroWeb/omeroweb/settings.py b/components/tools/OmeroWeb/omeroweb/settings.py index 2fd550da489..017da8a9f4b 100644 --- a/components/tools/OmeroWeb/omeroweb/settings.py +++ b/components/tools/OmeroWeb/omeroweb/settings.py @@ -472,7 +472,15 @@ def leave_none_unset_int(s): ("Workers silent for more than this many seconds are killed " "and restarted. Check Gunicorn Documentation " "http://docs.gunicorn.org/en/stable/settings.html#timeout")], - + "omero.web.api.absolute_url": + ["API_ABSOLUTE_URL", + None, + str_slash, + ("URL to use for generating urls within API json responses. " + "By default this is None, and we use Django's " + "request.build_absolute_uri() to generate absolute urls " + "based on each request. If set to a string or empty string, " + "this will be used as prefix to relative urls.")], # Public user "omero.web.public.enabled": @@ -1155,6 +1163,12 @@ def report_settings(module): # FEEDBACK_APP: 6 = OMERO.web FEEDBACK_APP = 6 +# For any given release of api, we may support +# one or more versions of the api. +# E.g. /api/v1.0/ +# TODO - need to decide how this is configured, strategy for extending etc. +API_VERSIONS = ('0.1',) + # IGNORABLE_404_STARTS: # Default: ('/cgi-bin/', '/_vti_bin', '/_vti_inf') # IGNORABLE_404_ENDS: diff --git a/components/tools/OmeroWeb/omeroweb/urls.py b/components/tools/OmeroWeb/omeroweb/urls.py index ddc44edafb2..9e0661ef500 100755 --- a/components/tools/OmeroWeb/omeroweb/urls.py +++ b/components/tools/OmeroWeb/omeroweb/urls.py @@ -100,6 +100,8 @@ def redirect_urlpatterns(): (r'^(?i)url/', include('omeroweb.webredirect.urls')), (r'^(?i)feedback/', include('omeroweb.feedback.urls')), + (r'^(?i)api/', include('omeroweb.webgateway.urls_api')), + url(r'^index/$', 'omeroweb.webclient.views.custom_index', name="webindex_custom"), ) diff --git a/components/tools/OmeroWeb/omeroweb/webadmin/urls.py b/components/tools/OmeroWeb/omeroweb/webadmin/urls.py index f3f936b702c..c3d282ab60d 100644 --- a/components/tools/OmeroWeb/omeroweb/webadmin/urls.py +++ b/components/tools/OmeroWeb/omeroweb/webadmin/urls.py @@ -26,13 +26,14 @@ from django.conf.urls import url, patterns from omeroweb.webadmin import views -from omeroweb.webclient import views as webclient_views +from omeroweb.webclient.views import WebclientLoginView # url patterns urlpatterns = patterns( '', url(r'^$', views.index, name="waindex"), - url(r'^login/$', webclient_views.login, name="walogin"), + url(r'^login/$', WebclientLoginView.as_view(), + name="walogin"), url(r'^logout/$', views.logout, name="walogout"), url(r'^forgottenpassword/$', views.forgotten_password, name="waforgottenpassword"), diff --git a/components/tools/OmeroWeb/omeroweb/webclient/urls.py b/components/tools/OmeroWeb/omeroweb/webclient/urls.py index d1160cc94a2..aba95377408 100644 --- a/components/tools/OmeroWeb/omeroweb/webclient/urls.py +++ b/components/tools/OmeroWeb/omeroweb/webclient/urls.py @@ -47,7 +47,7 @@ views.load_template, {'menu': 'history'}, name="history"), - url(r'^login/$', views.login, name="weblogin"), + url(r'^login/$', views.WebclientLoginView.as_view(), name="weblogin"), url(r'^logout/$', views.logout, name="weblogout"), url(r'^active_group/$', views.change_active_group, diff --git a/components/tools/OmeroWeb/omeroweb/webclient/views.py b/components/tools/OmeroWeb/omeroweb/webclient/views.py index 712a3ecf4cb..c171ebf8383 100755 --- a/components/tools/OmeroWeb/omeroweb/webclient/views.py +++ b/components/tools/OmeroWeb/omeroweb/webclient/views.py @@ -46,7 +46,8 @@ from django.conf import settings from django.template import loader as template_loader -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect, \ + JsonResponse from django.http import HttpResponseServerError, HttpResponseBadRequest from django.template import RequestContext as Context from django.utils.http import urlencode @@ -74,21 +75,17 @@ from controller.share import BaseShare from omeroweb.webadmin.forms import LoginForm -from omeroweb.webadmin.webadmin_utils import upgradeCheck from omeroweb.webgateway import views as webgateway_views from omeroweb.webgateway.marshal import chgrpMarshal from omeroweb.feedback.views import handlerInternalError -from omeroweb.http import HttpJsonResponse from omeroweb.webclient.decorators import login_required from omeroweb.webclient.decorators import render_response from omeroweb.webclient.show import Show, IncorrectMenuError, \ paths_to_object, paths_to_tag -from omeroweb.connector import Connector from omeroweb.decorators import ConnCleaningHttpResponse, parse_url -from omeroweb.decorators import get_client_ip from omeroweb.webgateway.util import getIntOrDefault from omero.model import ProjectI, DatasetI, ImageI, \ @@ -177,9 +174,10 @@ def custom_index(request, conn=None, **kwargs): # views -def login(request): +class WebclientLoginView(webgateway_views.LoginView): """ - Webclient Login - Also can be used by other Apps to log in to OMERO. Uses + Webclient Login - Customises the superclass LoginView + for webclient. Also can be used by other Apps to log in to OMERO. Uses the 'server' id from request to lookup the server-id (index), host and port from settings. E.g. "localhost", 4064. Stores these details, along with username, password etc in the request.session. Resets other data @@ -189,108 +187,80 @@ def login(request): with appropriate error messages. """ - request.session.modified = True - - conn = None - error = None - - form = LoginForm(data=request.POST.copy()) + template = "webclient/login.html" useragent = 'OMERO.web' - if form.is_valid(): - username = form.cleaned_data['username'] - password = form.cleaned_data['password'] - server_id = form.cleaned_data['server'] - is_secure = form.cleaned_data['ssl'] - - connector = Connector(server_id, is_secure) - - # TODO: version check should be done on the low level, see #5983 - compatible = True - if settings.CHECK_VERSION: - compatible = connector.check_version(useragent) - if (server_id is not None and username is not None and - password is not None and compatible): - conn = connector.create_connection( - useragent, username, password, userip=get_client_ip(request)) - if conn is not None: - # Check if user is in "user" group - roles = conn.getAdminService().getSecurityRoles() - userGroupId = roles.userGroupId - if userGroupId in conn.getEventContext().memberOfGroups: - request.session['connector'] = connector - # UpgradeCheck URL should be loaded from the server or - # loaded omero.web.upgrades.url allows to customize web - # only - try: - upgrades_url = settings.UPGRADES_URL - except: - upgrades_url = conn.getUpgradesUrl() - upgradeCheck(url=upgrades_url) - # if 'active_group' remains in session from previous - # login, check it's valid for this user - if request.session.get('active_group'): - if (request.session.get('active_group') not in - conn.getEventContext().memberOfGroups): - del request.session['active_group'] - if request.session.get('user_id'): - # always want to revert to logged-in user - del request.session['user_id'] - if request.session.get('server_settings'): - # always clean when logging in - del request.session['server_settings'] - # do we ned to display server version ? - # server_version = conn.getServerVersion() - if request.POST.get('noredirect'): - return HttpResponse('OK') - url = request.GET.get("url") - if url is None or len(url) == 0: - try: - url = parse_url(settings.LOGIN_REDIRECT) - except: - url = reverse("webindex") - return HttpResponseRedirect(url) - else: - error = "This user is not active." - if not connector.is_server_up(useragent): - error = "Server is not responding, please contact administrator." - elif not settings.CHECK_VERSION: - error = ("Connection not available, please check your" - " credentials and version compatibility.") - else: - if not compatible: - error = ("Client version does not match server," - " please contact administrator.") + def get(self, request): + """ + GET simply returns the login page + """ + return self.handle_not_logged_in(request) + + def handle_logged_in(self, request, conn, connector): + """ + We override this to provide webclient-specific functionality + such as cleaning up any previous sessions (if user didn't logout) + and redirect to specified url or webclient index page. + """ + + # webclient has various state that needs cleaning up... + # if 'active_group' remains in session from previous + # login, check it's valid for this user + if request.session.get('active_group'): + if (request.session.get('active_group') not in + conn.getEventContext().memberOfGroups): + del request.session['active_group'] + if request.session.get('user_id'): + # always want to revert to logged-in user + del request.session['user_id'] + if request.session.get('server_settings'): + # always clean when logging in + del request.session['server_settings'] + # do we ned to display server version ? + # server_version = conn.getServerVersion() + if request.POST.get('noredirect'): + return HttpResponse('OK') + url = request.GET.get("url") + if url is None or len(url) == 0: + try: + url = parse_url(settings.LOGIN_REDIRECT) + except: + url = reverse("webindex") + return HttpResponseRedirect(url) + + def handle_not_logged_in(self, request, error=None, form=None): + """ + Returns a response for failed login. + Reason for failure may be due to server 'error' or because + of form validation errors. + + @param request: http request + @param error: Error message + @param form: Instance of Login Form, populated with data + """ + if form is None: + server_id = request.GET.get('server', request.POST.get('server')) + if server_id is not None: + initial = {'server': unicode(server_id)} + form = LoginForm(initial=initial) else: - error = ("Connection not available, please check your" - " user name and password.") - - url = request.GET.get("url") - - template = "webclient/login.html" - if request.method != 'POST': - server_id = request.GET.get('server', request.POST.get('server')) - if server_id is not None: - initial = {'server': unicode(server_id)} - form = LoginForm(initial=initial) - else: - form = LoginForm() - - context = { - 'version': omero_version, - 'build_year': build_year, - 'error': error, - 'form': form} - if url is not None and len(url) != 0: - context['url'] = urlencode({'url': url}) - - if hasattr(settings, 'LOGIN_LOGO'): - context['LOGIN_LOGO'] = settings.LOGIN_LOGO - - t = template_loader.get_template(template) - c = Context(request, context) - rsp = t.render(c) - return HttpResponse(rsp) + form = LoginForm() + context = { + 'version': omero_version, + 'build_year': build_year, + 'error': error, + 'form': form} + url = request.GET.get("url") + if url is not None and len(url) != 0: + context['url'] = urlencode({'url': url}) + + if hasattr(settings, 'LOGIN_LOGO'): + context['LOGIN_LOGO'] = settings.LOGIN_LOGO + + t = template_loader.get_template(self.template) + c = Context(request, context) + rsp = t.render(c) + return HttpResponse(rsp) @login_required(ignore_login_fail=True) @@ -554,7 +524,7 @@ def api_group_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'groups': groups}) + return JsonResponse({'groups': groups}) @login_required() @@ -573,7 +543,7 @@ def api_experimenter_list(request, conn=None, **kwargs): group_id=group_id, page=page, limit=limit) - return HttpJsonResponse({'experimenters': experimenters}) + return JsonResponse({'experimenters': experimenters}) except ApiUsageException as e: return HttpResponseBadRequest(e.serverStackTrace) except ServerError as e: @@ -598,7 +568,7 @@ def api_experimenter_detail(request, experimenter_id, conn=None, **kwargs): # Get the experimenter experimenter = tree.marshal_experimenter( conn=conn, experimenter_id=experimenter_id) - return HttpJsonResponse({'experimenter': experimenter}) + return JsonResponse({'experimenter': experimenter}) except ApiUsageException as e: return HttpResponseBadRequest(e.serverStackTrace) @@ -686,7 +656,7 @@ def api_container_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse(r) + return JsonResponse(r) @login_required() @@ -714,7 +684,7 @@ def api_dataset_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'datasets': datasets}) + return JsonResponse({'datasets': datasets}) @login_required() @@ -769,7 +739,7 @@ def api_image_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'images': images}) + return JsonResponse({'images': images}) @login_required() @@ -797,7 +767,7 @@ def api_plate_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'plates': plates}) + return JsonResponse({'plates': plates}) @login_required() @@ -826,7 +796,7 @@ def api_plate_acquisition_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'acquisitions': plate_acquisitions}) + return JsonResponse({'acquisitions': plate_acquisitions}) def get_object_links(conn, parent_type, parent_id, child_type, child_ids): @@ -985,7 +955,7 @@ def _api_links_POST(conn, json_data, **kwargs): pass response['success'] = True - return HttpJsonResponse(response) + return JsonResponse(response) def _api_links_DELETE(conn, json_data): @@ -1036,7 +1006,7 @@ def _api_links_DELETE(conn, json_data): # If we got here, DELETE was OK response['success'] = True - return HttpJsonResponse(response) + return JsonResponse(response) @login_required() @@ -1079,7 +1049,7 @@ def api_paths_to_object(request, conn=None, **kwargs): paths = paths_to_object(conn, experimenter_id, project_id, dataset_id, image_id, screen_id, plate_id, acquisition_id, well_id, group_id) - return HttpJsonResponse({'paths': paths}) + return JsonResponse({'paths': paths}) @login_required() @@ -1138,7 +1108,7 @@ def api_tags_and_tagged_list_GET(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse(tagged) + return JsonResponse(tagged) def api_tags_and_tagged_list_DELETE(request, conn=None, **kwargs): @@ -1173,7 +1143,7 @@ def api_tags_and_tagged_list_DELETE(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse('') + return JsonResponse('') @login_required() @@ -1202,7 +1172,7 @@ def api_annotations(request, conn=None, **kwargs): page=page, limit=limit) - return HttpJsonResponse({'annotations': anns, 'experimenters': exps}) + return JsonResponse({'annotations': anns, 'experimenters': exps}) @login_required() @@ -1240,7 +1210,7 @@ def api_share_list(request, conn=None, **kwargs): except IceException as e: return HttpResponseServerError(e.message) - return HttpJsonResponse({'shares': shares, 'discussions': discussions}) + return JsonResponse({'shares': shares, 'discussions': discussions}) @login_required() @@ -2168,7 +2138,7 @@ def annotate_file(request, conn=None, **kwargs): newFileId = manager.createFileAnnotations( fileupload, oids, well_index=index) added_files.append(newFileId) - return HttpJsonResponse({'fileIds': added_files}) + return JsonResponse({'fileIds': added_files}) else: return HttpResponse(form_file.errors) @@ -2198,7 +2168,7 @@ def annotate_rating(request, conn=None, **kwargs): o.setRating(rating) # return a summary of ratings - return HttpJsonResponse({'success': True}) + return JsonResponse({'success': True}) @login_required() @@ -2499,9 +2469,9 @@ def annotate_tags(request, conn=None, **kwargs): "%s-%s" % (dtype, obj.id) for dtype, objs in oids.items() for obj in objs], index, tag_owner_id=self_id) - return HttpJsonResponse({'added': tags, - 'removed': removed, - 'new': new_tags}) + return JsonResponse({'added': tags, + 'removed': removed, + 'new': new_tags}) else: # TODO: handle invalid form error return HttpResponse(str(form_tags.errors)) @@ -2611,13 +2581,13 @@ def manage_action_containers(request, action, o_type=None, o_id=None, description = form.cleaned_data['description'] oid = manager.createDataset(name, description) rdict = {'bad': 'false', 'id': oid} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: d = dict() for e in form.errors.iteritems(): d.update({e[0]: unicode(e[1])}) rdict = {'bad': 'true', 'errs': d} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) elif o_type == "tagset" and o_id > 0: form = ContainerForm(data=request.POST.copy()) if form.is_valid(): @@ -2625,13 +2595,13 @@ def manage_action_containers(request, action, o_type=None, o_id=None, description = form.cleaned_data['description'] oid = manager.createTag(name, description) rdict = {'bad': 'false', 'id': oid} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: d = dict() for e in form.errors.iteritems(): d.update({e[0]: unicode(e[1])}) rdict = {'bad': 'true', 'errs': d} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) elif request.POST.get('folder_type') in ("project", "screen", "dataset", "tag", "tagset"): # No parent specified. We can create orphaned 'project', 'dataset' @@ -2651,13 +2621,13 @@ def manage_action_containers(request, action, o_type=None, o_id=None, oid = getattr(manager, "create" + folder_type.capitalize())(name, description) rdict = {'bad': 'false', 'id': oid} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: d = dict() for e in form.errors.iteritems(): d.update({e[0]: unicode(e[1])}) rdict = {'bad': 'true', 'errs': d} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: return HttpResponseServerError("Object does not exist") elif action == 'add': @@ -2781,13 +2751,13 @@ def manage_action_containers(request, action, o_type=None, o_id=None, manager.image = manager.well.getWellSample(index).image() o_type = "image" manager.updateName(o_type, name) - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: d = dict() for e in form.errors.iteritems(): d.update({e[0]: unicode(e[1])}) rdict = {'bad': 'true', 'errs': d} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: return HttpResponseServerError("Object does not exist") elif action == 'editdescription': @@ -2818,13 +2788,13 @@ def manage_action_containers(request, action, o_type=None, o_id=None, o_type = "image" manager.updateDescription(o_type, description) rdict = {'bad': 'false'} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: d = dict() for e in form.errors.iteritems(): d.update({e[0]: unicode(e[1])}) rdict = {'bad': 'true', 'errs': d} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) else: return HttpResponseServerError("Object does not exist") elif action == 'remove': @@ -2837,10 +2807,10 @@ def manage_action_containers(request, action, o_type=None, o_id=None, except Exception, x: logger.error(traceback.format_exc()) rdict = {'bad': 'true', 'errs': str(x)} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) rdict = {'bad': 'false'} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) elif action == 'removefromshare': image_id = request.POST.get('source') try: @@ -2848,9 +2818,9 @@ def manage_action_containers(request, action, o_type=None, o_id=None, except Exception, x: logger.error(traceback.format_exc()) rdict = {'bad': 'true', 'errs': str(x)} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) rdict = {'bad': 'false'} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) elif action == 'delete': # Handles delete of a file attached to object. child = toBoolean(request.POST.get('child')) @@ -2874,7 +2844,7 @@ def manage_action_containers(request, action, o_type=None, o_id=None, rdict = {'bad': 'true', 'errs': str(x)} else: rdict = {'bad': 'false'} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) elif action == 'deletemany': # Handles multi-delete from jsTree. object_ids = { @@ -2920,7 +2890,7 @@ def manage_action_containers(request, action, o_type=None, o_id=None, raise else: rdict = {'bad': 'false'} - return HttpJsonResponse(rdict) + return JsonResponse(rdict) context['template'] = template return context @@ -3541,7 +3511,7 @@ def activities(request, conn=None, **kwargs): rv['inprogress'] = in_progress rv['failure'] = failure rv['jobs'] = len(request.session['callback']) - return HttpJsonResponse(rv) # json + return JsonResponse(rv) # json jobs = [] new_errors = False @@ -3594,7 +3564,7 @@ def activities_update(request, action, **kwargs): rv['removed'] = True else: rv['removed'] = False - return HttpJsonResponse(rv) + return JsonResponse(rv) else: for key, data in request.session['callback'].items(): if data['status'] != "in progress": @@ -4300,7 +4270,7 @@ def getObjectOwnerId(r): request.session.get('user_id')) # return HttpResponse("OK") - return HttpJsonResponse({'update': update}) + return JsonResponse({'update': update}) @login_required(setGroupContext=True) @@ -4321,7 +4291,7 @@ def script_run(request, scriptId, conn=None, **kwargs): # Delegate to run_script() for handling 'No processor available' rsp = run_script( request, conn, sId, inputMap, scriptName='Script') - return HttpJsonResponse(rsp) + return JsonResponse(rsp) else: raise params = scriptService.getParams(sId) @@ -4429,7 +4399,7 @@ def script_run(request, scriptId, conn=None, **kwargs): except: pass rsp = run_script(request, conn, sId, inputMap, scriptName) - return HttpJsonResponse(rsp) + return JsonResponse(rsp) @require_POST @@ -4455,7 +4425,7 @@ def ome_tiff_script(request, imageId, conn=None, **kwargs): inputMap['Format'] = wrap('OME-TIFF') rsp = run_script( request, conn, sId, inputMap, scriptName='Create OME-TIFF') - return HttpJsonResponse(rsp) + return JsonResponse(rsp) def run_script(request, conn, sId, inputMap, scriptName='Script'): diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/api_exceptions.py b/components/tools/OmeroWeb/omeroweb/webgateway/api_exceptions.py new file mode 100644 index 00000000000..ad24594249f --- /dev/null +++ b/components/tools/OmeroWeb/omeroweb/webgateway/api_exceptions.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +class BadRequestError(Exception): + """ + An exception that will result in a response status of 400 + due to invalid client input + """ + status = 400 + + def __init__(self, message, stacktrace=None): + super(BadRequestError, self).__init__(message) + self.stacktrace = stacktrace + + +class NotFoundError(Exception): + """ + An exception that will result in a response status of 404 + due to objects not being found + """ + status = 404 + + def __init__(self, message, stacktrace=None): + super(NotFoundError, self).__init__(message) + self.stacktrace = stacktrace + + +class CreatedObject(Exception): + """ + An exception that is thrown when new object created + that returns a status of 201 + """ + status = 201 + + def __init__(self, response): + super(CreatedObject, self).__init__(response) + self.response = response diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/api_marshal.py b/components/tools/OmeroWeb/omeroweb/webgateway/api_marshal.py new file mode 100644 index 00000000000..bc29769c782 --- /dev/null +++ b/components/tools/OmeroWeb/omeroweb/webgateway/api_marshal.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' Helper functions for views that handle object trees ''' + + +from omero_marshal import get_encoder + + +def normalize_objects(objects): + """ + Takes a list of dicts generated from omero_marshal and + normalizes the groups and owners into separate + lists. omero:details will only retain group and owner IDs. + """ + experimenters = {} + groups = {} + objs = [] + for o in objects: + exp = o['omero:details']['owner'] + experimenters[exp['@id']] = exp + o['omero:details']['owner'] = {'@id': exp['@id']} + grp = o['omero:details']['group'] + groups[grp['@id']] = grp + o['omero:details']['group'] = {'@id': grp['@id']} + objs.append(o) + experimenters = experimenters.values() + groups = groups.values() + return objs, {'experimenters': experimenters, 'experimenterGroups': groups} + + +def marshal_objects(objects, extras=None, normalize=False): + """ + Marshals a list of OMERO.model objects using omero_marshal + + @param extras: A dict of id:dict to add extra data to each object + @param normalize: If true, normalize groups & owners into separate lists + """ + + marshalled = [] + for o in objects: + encoder = get_encoder(o.__class__) + m = encoder.encode(o) + if extras is not None and o.id.val in extras: + m.update(extras[o.id.val]) + marshalled.append(m) + + if not normalize: + return {'data': marshalled} + data, extra_objs = normalize_objects(marshalled) + extra_objs['data'] = data + return extra_objs diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/api_query.py b/components/tools/OmeroWeb/omeroweb/webgateway/api_query.py new file mode 100644 index 00000000000..4ed4a738131 --- /dev/null +++ b/components/tools/OmeroWeb/omeroweb/webgateway/api_query.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' Helper functions for views that handle object trees ''' + +import omero + +from omero.rtypes import unwrap, rlong +from django.conf import settings + +from api_marshal import marshal_objects +from copy import deepcopy + + +def query_projects(conn, childCount=False, + group=None, owner=None, + page=1, limit=settings.PAGE, + normalize=False): + """ + Query OMERO and marshal omero.model.Projects. + + Build a query based on a number of parameters, + queries OMERO with the query service and + marshals Projects with omero_marshal. + + @param conn: BlitzGateway + @param childCount: If true, also load Dataset counts as omero:childCount + @param group: Filter by group Id + @param owner: Filter by owner Id + @param page: Pagination page. Default is 1 + @param limit: Page size + @param normalize: If true, marshal groups and experimenters separately + """ + qs = conn.getQueryService() + params = omero.sys.ParametersI() + if page: + params.page((page-1) * limit, limit) + ctx = deepcopy(conn.SERVICE_OPTS) + + # Set the desired group context and owner + if group is None: + group = -1 + ctx.setOmeroGroup(group) + where_clause = '' + if owner is not None and owner != -1: + params.add('owner', rlong(owner)) + where_clause = 'where project.details.owner.id = :owner' + + withChildCount = "" + if childCount: + withChildCount = """, (select count(id) from ProjectDatasetLink pdl + where pdl.parent=project.id)""" + + # Need to load owners specifically, else can be unloaded if group != -1 + query = """ + select project %s from Project project + join fetch project.details.owner + %s + order by lower(project.name), project.id + """ % (withChildCount, where_clause) + + projects = [] + extras = {} + if childCount: + result = qs.projection(query, params, ctx) + for p in result: + project = unwrap(p[0]) + projects.append(project) + extras[project.id.val] = {'omero:childCount': unwrap(p[1])} + else: + extras = None + result = qs.findAllByQuery(query, params, ctx) + for p in result: + projects.append(p) + + return marshal_objects(projects, extras=extras, normalize=normalize) diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/decorators.py b/components/tools/OmeroWeb/omeroweb/webgateway/decorators.py new file mode 100644 index 00000000000..c3e14d6fb26 --- /dev/null +++ b/components/tools/OmeroWeb/omeroweb/webgateway/decorators.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +"""Decorators for use with the webgateway application.""" + +import omero +import omeroweb.decorators +import logging +import traceback +from django.http import JsonResponse +from functools import update_wrapper +from api_exceptions import NotFoundError, BadRequestError, CreatedObject + + +logger = logging.getLogger(__name__) + + +class login_required(omeroweb.decorators.login_required): + """ + webgateway specific extension of the OMERO.web login_required() decorator. + """ + + def on_not_logged_in(self, request, url, error=None): + """ + Used for json api methods + """ + return JsonResponse({'message': 'Not logged in'}, + status=403) + + +class json_response(object): + """ + Class-based decorator for wrapping Django views methods. + Returns JsonResponse based on dict returned by views methods. + Also handles exceptions from views methods, returning + JsonResponse with appropriate status values. + """ + + def __init__(self): + """Initialises the decorator.""" + pass + + def handle_success(self, rv): + """ + Handle successful response from wrapped function. + + By default, we simply return a JsonResponse() but this can be + overwritten by subclasses if needed. + """ + return JsonResponse(rv) + + def handle_error(self, ex, trace): + """ + Handle errors from wrapped function. + + By default, we format exception or message and return this + as a JsonResponse with an appropriate status code. + """ + + # Default status is 500 'server error' + # But we try to handle all 'expected' errors appropriately + # TODO: handle omero.ConcurrencyException + status = 500 + if isinstance(ex, NotFoundError): + status = ex.status + if isinstance(ex, BadRequestError): + status = ex.status + trace = ex.stacktrace # Might be None + elif isinstance(ex, omero.SecurityViolation): + status = 403 + elif isinstance(ex, omero.ApiUsageException): + status = 400 + logger.debug(trace) + rsp_json = {"message": str(ex)} + if trace is not None: + rsp_json["stacktrace"] = trace + # In this case, there's no Error and the response + # is valid (status code is 201) + if isinstance(ex, CreatedObject): + status = ex.status + rsp_json = ex.response + return JsonResponse(rsp_json, status=status) + + def __call__(ctx, f): + """ + Returns the decorator. + + The decorator calls the wrapped function and + handles success or exception, returning a + JsonResponse + """ + def wrapped(request, *args, **kwargs): + logger.debug('json_response') + try: + rv = f(request, *args, **kwargs) + return ctx.handle_success(rv) + except Exception, ex: + trace = traceback.format_exc() + return ctx.handle_error(ex, trace) + return update_wrapper(wrapped, f) diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/urls.py b/components/tools/OmeroWeb/omeroweb/webgateway/urls.py index 15f539d73b7..19716501b7c 100644 --- a/components/tools/OmeroWeb/omeroweb/webgateway/urls.py +++ b/components/tools/OmeroWeb/omeroweb/webgateway/urls.py @@ -427,6 +427,7 @@ This url will retrieve all rendering definitions for a given image (id) """ + urlpatterns = patterns( '', webgateway, @@ -478,6 +479,4 @@ table_query, object_table_query, open_with_options, - # Debug stuff - ) diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/urls_api.py b/components/tools/OmeroWeb/omeroweb/webgateway/urls_api.py new file mode 100644 index 00000000000..6d8c5753f1e --- /dev/null +++ b/components/tools/OmeroWeb/omeroweb/webgateway/urls_api.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Handles all 'api' urls without including '/webgateway/' in the url. + +from django.conf.urls import url, patterns +from omeroweb.webgateway import views +from omeroweb.webgateway.views import LoginView +from django.conf import settings +import re + +versions = '|'.join([re.escape(v) + for v in settings.API_VERSIONS]) + +api_versions = url(r'^$', views.api_versions, name='api_versions') + +api_base = url(r'^v(?P%s)/$' % versions, + views.api_base, + name='api_base') +""" +GET various urls listed below +""" + +api_token = url(r'^v(?P%s)/token/$' % versions, + views.api_token, + name='api_token') +""" +GET the CSRF token for this session. Needs to be included +in header with all POST, PUT & DELETE requests +""" + +api_servers = url(r'^v(?P%s)/servers/$' % versions, + views.api_servers, + name='api_servers') +""" +GET list of available OMERO servers to login to. +""" + +api_login = url(r'^v(?P%s)/login/$' % versions, + LoginView.as_view(), + name='api_login') +""" +Login to OMERO. POST with 'username', 'password' and 'server' index +""" + +api_save = url(r'^v(?P%s)/m/save/$' % versions, + views.SaveView.as_view(), + name='api_save') +""" +POST to create a new object or PUT to update existing object. +In both cases content body encodes json data. +""" + +api_projects = url(r'^v(?P%s)/m/projects/$' % versions, + views.ProjectsView.as_view(), + name='api_projects') +""" +GET all projects, using omero-marshal to generate json +""" + +api_project = url( + r'^v(?P%s)/m/projects/(?P[0-9]+)/$' % versions, + views.ProjectView.as_view(), + name='api_project') +""" +Project url to GET or DELETE a single Project +""" + +urlpatterns = patterns( + '', + api_versions, + api_base, + api_token, + api_servers, + api_login, + api_save, + api_projects, + api_project, +) diff --git a/components/tools/OmeroWeb/omeroweb/webgateway/views.py b/components/tools/OmeroWeb/omeroweb/webgateway/views.py index 81986221e45..ca78024f5a1 100644 --- a/components/tools/OmeroWeb/omeroweb/webgateway/views.py +++ b/components/tools/OmeroWeb/omeroweb/webgateway/views.py @@ -19,20 +19,28 @@ import omero.clients from django.http import HttpResponse, HttpResponseBadRequest, \ - HttpResponseServerError + HttpResponseServerError, JsonResponse from django.http import HttpResponseRedirect, HttpResponseNotAllowed, Http404 from django.template import loader as template_loader from django.views.decorators.http import require_POST +from django.views.generic import View from django.core.urlresolvers import reverse, NoReverseMatch from django.conf import settings from django.template import RequestContext as Context from django.core.servers.basehttp import FileWrapper +from django.middleware import csrf +from django.utils.decorators import method_decorator from omero.rtypes import rlong, unwrap from omero.constants.namespaces import NSBULKANNOTATIONS from omero.util.ROI_utils import pointsStringToXYlist, xyListToBbox from plategrid import PlateGrid from omero_version import build_year from marshal import imageMarshal, shapeMarshal, rgb_int2rgba +from api_query import query_projects +from omeroweb.webadmin.forms import LoginForm +from omeroweb.decorators import get_client_ip +from omeroweb.webadmin.webadmin_utils import upgradeCheck +from omero_marshal import get_encoder, get_decoder, OME_SCHEMA_URL from django.contrib.staticfiles.templatetags.staticfiles import static try: @@ -43,9 +51,10 @@ from cStringIO import StringIO import tempfile -from omero import ApiUsageException +from omero import ApiUsageException, ValidationException from omero.util.decorators import timeit, TimeIt -from omeroweb.http import HttpJavascriptResponse, HttpJsonResponse, \ +from omeroweb.connector import Server +from omeroweb.http import HttpJavascriptResponse, \ HttpJavascriptResponseServerError import glob @@ -55,8 +64,6 @@ from webgateway_cache import webgateway_cache, CacheBase, webgateway_tempfile -cache = CacheBase() - import logging import os import traceback @@ -65,9 +72,14 @@ import shutil from omeroweb.decorators import login_required, ConnCleaningHttpResponse +from omeroweb.webgateway.decorators import login_required as api_login_required, \ + json_response from omeroweb.connector import Connector from omeroweb.webgateway.util import zip_archived_files, getIntOrDefault +from omeroweb.webgateway.api_exceptions import BadRequestError, NotFoundError,\ + CreatedObject +cache = CacheBase() logger = logging.getLogger(__name__) try: @@ -1081,8 +1093,8 @@ def render_ome_tiff(request, ctx, cid, conn=None, **kwargs): except: logger.debug(traceback.format_exc()) raise - return HttpResponseRedirect(settings.STATIC_URL + 'webgateway/tfiles/' - + rpath) + return HttpResponseRedirect(settings.STATIC_URL + + 'webgateway/tfiles/' + rpath) @login_required() @@ -1241,24 +1253,35 @@ def wrap(request, *args, **kwargs): return rv if isinstance(rv, HttpResponse): return rv - rv = json.dumps(rv) c = request.GET.get('callback', None) if c is not None and not kwargs.get('_internal', False): + rv = json.dumps(rv) rv = '%s(%s)' % (c, rv) + # mimetype for JSONP is application/javascript + return HttpJavascriptResponse(rv) if kwargs.get('_internal', False): return rv - return HttpJavascriptResponse(rv) - except omero.ServerError: + # mimetype for JSON is application/json + # NB: To support old api E.g. /get_rois_json/ + # We need to support lists + safe = type(rv) is dict + return JsonResponse(rv, safe=safe) + except Exception, ex: + # Default status is 500 'server error' + # But we try to handle all 'expected' errors appropriately + # TODO: handle omero.ConcurrencyException + status = 500 + if isinstance(ex, omero.SecurityViolation): + status = 403 + elif isinstance(ex, omero.ApiUsageException): + status = 400 + trace = traceback.format_exc() + logger.debug(trace) if kwargs.get('_raw', False) or kwargs.get('_internal', False): raise - return HttpJavascriptResponseServerError( - '("error in call","%s")' % traceback.format_exc()) - except: - logger.debug(traceback.format_exc()) - if kwargs.get('_raw', False) or kwargs.get('_internal', False): - raise - return HttpJavascriptResponseServerError( - '("error in call","%s")' % traceback.format_exc()) + return JsonResponse( + {"message": str(ex), "stacktrace": trace}, + status=status) wrap.func_name = f.func_name return wrap @@ -1775,8 +1798,8 @@ def compat(i): return False pp = i.getPrimaryPixels() if (pp is None or - i.getPrimaryPixels().getPixelsType().getValue() != img_ptype - or i.getSizeC() != img_ccount): + i.getPrimaryPixels().getPixelsType().getValue() != img_ptype or + i.getSizeC() != img_ccount): return False ew = [x.getLabel() for x in i.getChannels()] ew.sort() @@ -2309,7 +2332,7 @@ def get_shape_json(request, roiId, shapeId, conn=None, **kwargs): if shape is None: logger.debug('No such shape: %r' % shapeId) raise Http404 - return HttpJsonResponse(shapeMarshal(shape)) + return JsonResponse(shapeMarshal(shape)) @login_required() @@ -2581,6 +2604,334 @@ def object_table_query(request, objtype, objid, conn=None, **kwargs): return tableData +def build_url(request, name, api_version, **kwargs): + """ + Helper for generating urls within /api json responses + By default we use request.build_absolute_uri() but this + can be configured by setting "omero.web.api.absolute_url" + to a string or empty string, used to prefix relative urls. + Extra **kwargs are passed to reverse() function. + + @param name: Name of the url + @param api_version Version string + """ + kwargs['api_version'] = api_version + url = reverse(name, kwargs=kwargs) + if settings.API_ABSOLUTE_URL is None: + return request.build_absolute_uri(url) + else: + # remove trailing slash + prefix = settings.API_ABSOLUTE_URL.rstrip('/') + return "%s%s" % (prefix, url) + + +@json_response() +def api_versions(request, **kwargs): + """ + Base url of the webgateway json api. + """ + versions = [] + for v in settings.API_VERSIONS: + versions.append({ + 'version': v, + 'base_url': build_url(request, 'api_base', v) + }) + return {'data': versions} + + +@json_response() +def api_base(request, api_version=None, **kwargs): + """ + Base url of the webgateway json api for a specified version. + """ + v = api_version + rv = {'projects_url': build_url(request, 'api_projects', v), + 'token_url': build_url(request, 'api_token', v), + 'servers_url': build_url(request, 'api_servers', v), + 'login_url': build_url(request, 'api_login', v), + 'save_url': build_url(request, 'api_save', v), + 'schema_url': OME_SCHEMA_URL} + return rv + + +@json_response() +def api_token(request, api_version, **kwargs): + """ + Provides CSRF token for current session + """ + token = csrf.get_token(request) + return {'data': token} + + +@json_response() +def api_servers(request, api_version, **kwargs): + """ + Lists the available servers to connect to + """ + servers = [] + for i, obj in enumerate(Server): + s = {'id': i + 1, + 'host': obj.host, + 'port': obj.port + } + if obj.server is not None: + s['server'] = obj.server + servers.append(s) + return {'data': servers} + + +class LoginView(View): + """ + Webgateway Login - Subclassed by WebclientLoginView + """ + + form_class = LoginForm + useragent = 'OMERO.webapi' + + def get(self, request, api_version=None): + """ Simply return a message to say GET not supported """ + return JsonResponse({"message": + ("POST only with username, password, " + "server and csrftoken")}, + status=405) + + def handle_logged_in(self, request, conn, connector): + """ Returns a response for successful login """ + c = conn.getEventContext() + ctx = {} + for a in ['sessionId', 'sessionUuid', 'userId', 'userName', 'groupId', + 'groupName', 'isAdmin', 'eventId', 'eventType', + 'memberOfGroups', 'leaderOfGroups']: + if (hasattr(c, a)): + ctx[a] = getattr(c, a) + return JsonResponse({"success": True, "eventContext": ctx}) + + def handle_not_logged_in(self, request, error=None, form=None): + """ + Returns a response for failed login. + Reason for failure may be due to server 'error' or because + of form validation errors. + + @param request: http request + @param error: Error message + @param form: Instance of Login Form, populated with data + """ + if error is None and form is not None: + # If no error from server, maybe form wasn't valid + formErrors = [] + for field in form: + for e in field.errors: + formErrors.append("%s: %s" % (field.label, e)) + error = " ".join(formErrors) + elif error is None: + # Just in case no error or invalid form is given + error = "Login failed. Reason unknown." + return JsonResponse({"message": error}, status=403) + + def post(self, request, api_version=None): + """ + Here we handle the main login logic, creating a connection to OMERO + and storing that on the request.session OR handling login failures + """ + error = None + form = self.form_class(request.POST.copy()) + if form.is_valid(): + username = form.cleaned_data['username'] + password = form.cleaned_data['password'] + server_id = form.cleaned_data['server'] + is_secure = form.cleaned_data['ssl'] + + connector = Connector(server_id, is_secure) + + # TODO: version check should be done on the low level, see #5983 + compatible = True + if settings.CHECK_VERSION: + compatible = connector.check_version(self.useragent) + if (server_id is not None and username is not None and + password is not None and compatible): + conn = connector.create_connection( + self.useragent, username, password, + userip=get_client_ip(request)) + if conn is not None: + request.session['connector'] = connector + # UpgradeCheck URL should be loaded from the server or + # loaded omero.web.upgrades.url allows to customize web + # only + try: + upgrades_url = settings.UPGRADES_URL + except: + upgrades_url = conn.getUpgradesUrl() + upgradeCheck(url=upgrades_url) + return self.handle_logged_in(request, conn, connector) + # Once here, we are not logged in... + # Need correct error message + if not connector.is_server_up(self.useragent): + error = ("Server is not responding," + " please contact administrator.") + elif not settings.CHECK_VERSION: + error = ("Connection not available, please check your" + " credentials and version compatibility.") + else: + if not compatible: + error = ("Client version does not match server," + " please contact administrator.") + else: + error = ("Connection not available, please check your" + " user name and password.") + return self.handle_not_logged_in(request, error, form) + + +class ProjectView(View): + """ + Handles access to an individual Project to GET or DELETE it + """ + + @method_decorator(api_login_required(useragent='OMERO.webapi')) + @method_decorator(json_response()) + def dispatch(self, *args, **kwargs): + return super(ProjectView, self).dispatch(*args, **kwargs) + + def get(self, request, pid, conn=None, **kwargs): + """ Simply GET a single Project and marshal it or 404 if not found """ + project = conn.getObject("Project", pid) + if project is None: + raise NotFoundError('Project %s not found' % pid) + encoder = get_encoder(project._obj.__class__) + return encoder.encode(project._obj) + + def delete(self, request, pid, conn=None, **kwargs): + """ + Deletes the Project and returns marshal of deleted Project or + returns 404 if not found + """ + try: + project = conn.getQueryService().get('Project', long(pid)) + except ValidationException: + raise NotFoundError('Project %s not found' % pid) + encoder = get_encoder(project.__class__) + json = encoder.encode(project) + conn.deleteObject(project) + return json + + +class ProjectsView(View): + """ + Handles GET for /projects/ to list available Projects + """ + + @method_decorator(api_login_required(useragent='OMERO.webapi')) + @method_decorator(json_response()) + def dispatch(self, *args, **kwargs): + """ Use dispatch to add decorators to class methods """ + return super(ProjectsView, self).dispatch(*args, **kwargs) + + def get(self, request, conn=None, **kwargs): + """ + GET a list of Projects, filtering by various request parameters + """ + try: + page = getIntOrDefault(request, 'page', 1) + limit = getIntOrDefault(request, 'limit', settings.PAGE) + group = getIntOrDefault(request, 'group', -1) + owner = getIntOrDefault(request, 'owner', -1) + childCount = request.GET.get('childCount', False) == 'true' + normalize = request.GET.get('normalize', False) == 'true' + except ValueError as ex: + raise BadRequestError(str(ex)) + + # Get the projects + projects = query_projects(conn, + group=group, + owner=owner, + childCount=childCount, + page=page, + limit=limit, + normalize=normalize) + + return projects + + +class SaveView(View): + """ + This view provides 'Save' functionality for all types of objects + POST to create a new Object and PUT to replace existing one. + """ + + @method_decorator(api_login_required(useragent='OMERO.webapi')) + @method_decorator(json_response()) + def dispatch(self, *args, **kwargs): + """ Apply decorators for class methods below """ + return super(SaveView, self).dispatch(*args, **kwargs) + + def put(self, request, conn=None, **kwargs): + """ + PUT handles saving of existing objects. + Therefore '@id' should be set. + """ + object_json = json.loads(request.body) + if '@id' not in object_json: + raise BadRequestError( + "No '@id' attribute. Use POST to create new objects") + return self._save_object(request, conn, object_json, **kwargs) + + def post(self, request, conn=None, **kwargs): + """ + POST handles saving of NEW objects. + Therefore '@id' should not be set. + """ + object_json = json.loads(request.body) + if '@id' in object_json: + raise BadRequestError( + "Object has '@id' attribute. Use PUT to update objects") + rsp = self._save_object(request, conn, object_json, **kwargs) + # will return 201 ('Created') + raise CreatedObject(rsp) + + def _save_object(self, request, conn, object_json, **kwargs): + """ + Here we handle the saving for PUT and POST + """ + # Try to get group from request, OR from details below... + group = getIntOrDefault(request, 'group', None) + decoder = None + if '@type' not in object_json: + raise BadRequestError('Need to specify @type attribute') + objType = object_json['@type'] + decoder = get_decoder(objType) + # If we are passed incomplete object, or decoder couldn't be found... + if decoder is None: + raise BadRequestError('No decoder found for type: %s' % objType) + + # Any marshal errors most likely due to invalid input. status=400 + try: + obj = decoder.decode(object_json) + except Exception: + msg = 'Error in decode of json data by omero_marshal' + raise BadRequestError(msg, traceback.format_exc()) + + if group is None: + try: + # group might be None or unloaded + group = obj.getDetails().group.id.val + except AttributeError: + # Instead of default stack trace, give nicer message: + msg = ("Specify Group in omero:details or " + "query parameters ?group=:id") + raise BadRequestError(msg) + + # If owner was unloaded (E.g. from get() above) or if missing + # ome.model.meta.Experimenter.ldap (not supported by omero_marshal) + # then saveObject() will give ValidationException. + # Therefore we ignore any details for now: + obj.unloadDetails() + + conn.SERVICE_OPTS.setOmeroGroup(group) + obj = conn.getUpdateService().saveAndReturnObject(obj, + conn.SERVICE_OPTS) + encoder = get_encoder(obj.__class__) + return encoder.encode(obj) + + @login_required() @jsonp def get_image_rdefs_json(request, img_id=None, conn=None, **kwargs): diff --git a/components/tools/OmeroWeb/requirements-common.txt b/components/tools/OmeroWeb/requirements-common.txt index d939edddc0f..89392cac3cf 100644 --- a/components/tools/OmeroWeb/requirements-common.txt +++ b/components/tools/OmeroWeb/requirements-common.txt @@ -6,4 +6,4 @@ Django>=1.8,<1.9 django-pipeline==1.3.20 -git+git://github.com/openmicroscopy/omero-marshal.git@v0.4.0#egg=omero-marshal +omero-marshal==0.4.1 diff --git a/components/tools/OmeroWeb/test/integration/test_api_errors.py b/components/tools/OmeroWeb/test/integration/test_api_errors.py new file mode 100644 index 00000000000..6f4e2206031 --- /dev/null +++ b/components/tools/OmeroWeb/test/integration/test_api_errors.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Tests querying & editing Projects with webgateway json api +""" + +from weblibrary import IWebTest, _csrf_post_json, _csrf_put_json +from django.core.urlresolvers import reverse +from django.conf import settings +from omero.gateway import BlitzGateway +import pytest +from omero.model import ProjectI +from omero.rtypes import rstring +from omero_marshal import get_encoder, get_decoder, OME_SCHEMA_URL +from omero import ValidationException + + +def get_connection(user, group_id=None): + """ + Get a BlitzGateway connection for the given user's client + """ + connection = BlitzGateway(client_obj=user[0]) + # Refresh the session context + connection.getEventContext() + if group_id is not None: + connection.SERVICE_OPTS.setOmeroGroup(group_id) + return connection + + +class TestErrors(IWebTest): + """ + Tests the response status with various error types + """ + + # Create a read-annotate group + @pytest.fixture(scope='function') + def group_A(self): + """Returns a new read-only group.""" + return self.new_group(perms='rwra--') + + # Create a read-only group + @pytest.fixture(scope='function') + def group_B(self): + """Returns a new read-only group.""" + return self.new_group(perms='rwr---') + + # Create users in the read-only group + @pytest.fixture() + def user_A(self, group_A, group_B): + """Returns a new user in the group_A group and also add to group_B""" + user = self.new_client_and_user(group=group_A) + self.add_groups(user[1], [group_B]) + return user + + def test_save_post_no_id(self): + """ If POST to /save/ data shouldn't contain @id """ + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + payload = {'Name': 'test_save_post_no_id', + '@id': 1} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + assert (rsp['message'] == + "Object has '@id' attribute. Use PUT to update objects") + + def test_save_put_id(self): + """ If PUT to /save/ to update, data must contain @id """ + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + payload = {'Name': 'test_save_put_id'} + rsp = _csrf_put_json(django_client, save_url, payload, + status_code=400) + assert (rsp['message'] == + "No '@id' attribute. Use POST to create new objects") + + def test_marshal_type(self): + """ If no decoder found for @type, get suitable message""" + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + objType = 'SomeInvalid#Type' + payload = {'Name': 'test_marshal_type', + '@type': objType} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + assert (rsp['message'] == + 'No decoder found for type: %s' % objType) + + def test_marshal_validation(self): + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + payload = {'Name': 'test_marshal_validation', + '@type': OME_SCHEMA_URL + '#Project', + 'omero:details': {'@type': 'foo'}} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + assert (rsp['message'] == + "Error in decode of json data by omero_marshal") + assert rsp['stacktrace'].startswith( + 'Traceback (most recent call last):') + + def test_security_violation(self, group_B, user_A): + conn = get_connection(user_A) + groupAid = conn.getEventContext().groupId + userName = conn.getUser().getName() + django_client = self.new_django_client(userName, userName) + version = settings.API_VERSIONS[-1] + groupBid = group_B.id.val + save_url = reverse('api_save', kwargs={'api_version': version}) + # Create project in group_A (default group) + payload = {'Name': 'test_security_violation', + '@type': OME_SCHEMA_URL + '#Project'} + save_url_grpA = save_url + '?group=' + str(groupAid) + pr_json = _csrf_post_json(django_client, save_url_grpA, payload, + status_code=201) + projectId = pr_json['@id'] + # Try to save again into group B + save_url_grpB = save_url + '?group=' + str(groupBid) + rsp = _csrf_put_json(django_client, save_url_grpB, pr_json, + status_code=403) + assert 'message' in rsp + msg = "Cannot read ome.model.containers.Project:Id_%s" % projectId + assert msg in rsp['message'] + assert rsp['stacktrace'].startswith( + 'Traceback (most recent call last):') + + def test_marshal_exception(self): + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + payload = {'Name': 'test_type_error', + '@type': OME_SCHEMA_URL + '#Project', + 'omero:details': {'@type': 'foo'}} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + assert (rsp['message'] == + "Error in decode of json data by omero_marshal") + assert rsp['stacktrace'].startswith( + 'Traceback (most recent call last):') + + def test_validation_exception(self, user_A): + conn = get_connection(user_A) + group = conn.getEventContext().groupId + userName = conn.getUser().getName() + django_client = self.new_django_client(userName, userName) + version = settings.API_VERSIONS[-1] + save_url = reverse('api_save', kwargs={'api_version': version}) + save_url += '?group=' + str(group) + + # Create Tag + tag = {'Value': 'test_tag', + '@type': OME_SCHEMA_URL + '#TagAnnotation'} + tag_rsp = _csrf_post_json(django_client, save_url, tag, + status_code=201) + + # Add Tag twice to Project to get Validation Exception + del tag_rsp['omero:details'] + payload = {'Name': 'test_validation', + '@type': OME_SCHEMA_URL + '#Project', + 'Annotations': [tag_rsp, tag_rsp]} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + # NB: message contains whole stack trace + assert "ValidationException" in rsp['message'] + assert rsp['stacktrace'].startswith( + 'Traceback (most recent call last):') + + def test_project_validation(self, user_A): + """ + This test illustrates the ValidationException we see when + Project is encoded to dict then decoded back to Project + and saved. + No exception is seen if the original Project is simply + saved without encode & decode OR if the details are unloaded + before saving + """ + conn = get_connection(user_A) + project = ProjectI() + project.name = rstring('test_project_validation') + project = conn.getUpdateService().saveAndReturnObject(project) + + # Saving original Project again is OK + conn.getUpdateService().saveObject(project) + + # encode and decode before Save raises Validation Exception + project_json = get_encoder(project.__class__).encode(project) + decoder = get_decoder(project_json['@type']) + p = decoder.decode(project_json) + with pytest.raises(ValidationException): + conn.getUpdateService().saveObject(p) + + p = decoder.decode(project_json) + # Unloading details allows Save without exception + p.unloadDetails() + conn.getUpdateService().saveObject(p) diff --git a/components/tools/OmeroWeb/test/integration/test_api_login.py b/components/tools/OmeroWeb/test/integration/test_api_login.py new file mode 100644 index 00000000000..532862f8500 --- /dev/null +++ b/components/tools/OmeroWeb/test/integration/test_api_login.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Tests logging in with webgateway json api +""" + +import pytest +from weblibrary import IWebTest, _get_response_json, _post_response_json, \ + _csrf_post_response_json +from django.core.urlresolvers import reverse, NoReverseMatch +from django.conf import settings +from django.test import Client +from omero_marshal import OME_SCHEMA_URL +import json + + +class TestLogin(IWebTest): + """ + Tests login workflow: getting url, csfv tokens etc. + """ + + def test_versions(self): + """ + Start at the base url, get versions + """ + django_client = self.django_root_client + request_url = reverse('api_versions') + rsp = _get_response_json(django_client, request_url, {}) + versions = rsp['data'] + assert len(versions) == len(settings.API_VERSIONS) + for v in versions: + assert v['version'] in settings.API_VERSIONS + + def test_base_url(self): + """ + Tests that the base url for a given version provides other urls + """ + django_client = self.django_root_client + # test the most recent version + version = settings.API_VERSIONS[-1] + request_url = reverse('api_base', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}) + assert 'servers_url' in rsp + assert 'login_url' in rsp + assert 'projects_url' in rsp + assert 'save_url' in rsp + assert rsp['schema_url'] == OME_SCHEMA_URL + + def test_base_url_versions_404(self): + """ + Tests that the base url gives 404 for invalid versions + """ + version = '0' + with pytest.raises(NoReverseMatch): + reverse('api_base', kwargs={'api_version': version}) + + def test_login_get(self): + """ + Tests that we get a suitable message if we try to GET login_url + """ + django_client = self.django_root_client + version = settings.API_VERSIONS[-1] + request_url = reverse('api_login', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}, + status_code=405) + assert (rsp['message'] == + "POST only with username, password, server and csrftoken") + + def test_login_csrf(self): + """ + Tests that we can only login with CSRF + """ + django_client = self.django_root_client + # test the most recent version + version = settings.API_VERSIONS[-1] + request_url = reverse('api_login', kwargs={'api_version': version}) + rsp = _post_response_json(django_client, request_url, {}, + status_code=403) + assert (rsp['message'] == + ("CSRF Error. You need to include valid CSRF tokens for any" + " POST, PUT, PATCH or DELETE operations." + " You have to include CSRF token in the POST data or" + " add the token to the HTTP header.")) + + @pytest.mark.parametrize("credentials", [ + [{'username': 'guest', 'password': 'fake', 'server': 1}, + "Username: Guest account is not supported."], + [{'username': 'nobody', 'password': '', 'server': 1}, + "Password: This field is required."], + [{'password': 'fake'}, + # No username OR server. Test concatenation of 2 errors + ("Username: This field is required. " + "Server: This field is required.")], + [{'username': 'nobody', 'password': 'fake', 'server': 1}, + ("Connection not available, " + "please check your user name and password.")] + ]) + def test_login_errors(self, credentials): + """ + Tests that we get expected form validation errors if try to login + without required fields, as 'guest' or with invalid username/password. + """ + django_client = self.django_root_client + # test the most recent version + version = settings.API_VERSIONS[-1] + request_url = reverse('api_login', kwargs={'api_version': version}) + data = credentials[0] + message = credentials[1] + rsp = _csrf_post_response_json(django_client, request_url, data, + status_code=403) + assert rsp['message'] == message + + def test_login_example(self): + """ + Example of successful login as user would do for real, + starting at base url and getting all other urls and info from there. + """ + # Create test user and get username & password for use below + user = self.new_user() + username = password = user.getOmeName().val + + # Django client, not logged in yet + django_client = Client() + # Start at the /api/ url to list versions... + request_url = reverse('api_versions') + rsp = _get_response_json(django_client, request_url, {}) + # Pick the last version + version = rsp['data'][-1] + base_url = version['base_url'] + # Base url will give a bunch of other urls + base_rsp = _get_response_json(django_client, base_url, {}) + login_url = base_rsp['login_url'] + servers_url = base_rsp['servers_url'] + login_url = base_rsp['login_url'] + token_url = base_rsp['token_url'] + # See what servers we can log in to + servers_rsp = _get_response_json(django_client, servers_url, {}) + server_id = servers_rsp['data'][0]['id'] + # Need a CSRF token + token_rsp = _get_response_json(django_client, token_url, {}) + token = token_rsp['data'] + # Can also get this from our session cookies + csrf_token = django_client.cookies['csrftoken'].value + assert token == csrf_token + # Now we have all info we need for login. + # Set the header, so we don't need to do this for every POST/PUT/DELETE + # OR we could add it to each POST as 'csrfmiddlewaretoken' + django_client = Client(HTTP_X_CSRFTOKEN=token) + data = { + 'username': username, + 'password': password, + 'server': server_id, + # 'csrfmiddlewaretoken': token, + } + login_rsp = django_client.post(login_url, data) + login_json = json.loads(login_rsp.content) + assert login_json['success'] + event_context = login_json['eventContext'] + # eventContext gives a bunch of info + member_of_groups = event_context['memberOfGroups'] + current_group = event_context['groupId'] + user_id = event_context['userId'] + assert len(member_of_groups) == 2 # includes 'user' group + assert current_group in member_of_groups + assert user_id > 0 diff --git a/components/tools/OmeroWeb/test/integration/test_api_projects.py b/components/tools/OmeroWeb/test/integration/test_api_projects.py new file mode 100644 index 00000000000..76c9f7d606b --- /dev/null +++ b/components/tools/OmeroWeb/test/integration/test_api_projects.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Tests querying & editing Projects with webgateway json api +""" + +from weblibrary import IWebTest, _get_response_json, \ + _csrf_post_json, _csrf_put_json, _csrf_delete_response_json +from django.core.urlresolvers import reverse +from django.conf import settings +from django.test import Client +import pytest +from omero.gateway import BlitzGateway +from omero.model import ProjectI, DatasetI +from omero.rtypes import unwrap, rstring +from omero_marshal import get_encoder, OME_SCHEMA_URL + + +def get_update_service(user): + """ + Get the update_service for the given user's client + """ + return user[0].getSession().getUpdateService() + + +def lower_or_none(x): + """ Lower the case or `None`""" + if x is not None: + return x.lower() + return None + + +def cmp_name_insensitive(x, y): + """Case-insensitive name comparator.""" + return cmp(lower_or_none(unwrap(x.name)), + lower_or_none(unwrap(y.name))) + + +def get_connection(user, group_id=None): + """ + Get a BlitzGateway connection for the given user's client + """ + connection = BlitzGateway(client_obj=user[0]) + # Refresh the session context + connection.getEventContext() + if group_id is not None: + connection.SERVICE_OPTS.setOmeroGroup(group_id) + return connection + + +# Some names +@pytest.fixture(scope='module') +def names1(request): + return ('Apple', 'bat') + + +@pytest.fixture(scope='module') +def names2(request): + return ('Axe',) + + +@pytest.fixture(scope='module') +def names3(request): + return ('Bark', 'custard') + + +# Projects +@pytest.fixture(scope='function') +def projects_user1_group1(request, names1, user1, + project_hierarchy_user1_group1): + """ + Returns new OMERO Projects with required fields set and with names + that can be used to exercise sorting semantics. + """ + to_save = [] + for name in names1: + project = ProjectI() + project.name = rstring(name) + to_save.append(project) + projects = get_update_service(user1).saveAndReturnArray(to_save) + projects.extend(project_hierarchy_user1_group1[:2]) + projects.sort(cmp_name_insensitive) + return projects + + +@pytest.fixture(scope='function') +def projects_user2_group1(request, names2, user2): + """ + Returns a new OMERO Project with required fields set and with a name + that can be used to exercise sorting semantics. + """ + to_save = [] + for name in names2: + project = ProjectI() + project.name = rstring(name) + to_save.append(project) + projects = get_update_service(user2).saveAndReturnArray( + to_save) + projects.sort(cmp_name_insensitive) + return projects + + +@pytest.fixture(scope='function') +def projects_user1_group2(request, names3, user1, group2): + """ + Returns new OMERO Projects with required fields set and with names + that can be used to exercise sorting semantics. + """ + to_save = [] + for name in names3: + project = ProjectI() + project.name = rstring(name) + to_save.append(project) + conn = get_connection(user1, group2.id.val) + projects = conn.getUpdateService().saveAndReturnArray(to_save, + conn.SERVICE_OPTS) + projects.sort(cmp_name_insensitive) + return projects + + +@pytest.fixture(scope='function') +def projects_user1(request, projects_user1_group1, + projects_user1_group2): + """ + Returns OMERO Projects for user1 in both group1 and group2 + """ + projects = projects_user1_group1 + projects_user1_group2 + projects.sort(cmp_name_insensitive) + return projects + + +def marshal_objects(objects): + """ Marshal objects using omero_marshal """ + expected = [] + for obj in objects: + encoder = get_encoder(obj.__class__) + expected.append(encoder.encode(obj)) + return expected + + +def assert_objects(conn, json_objects, omero_ids_objects, dtype="Project", + group='-1'): + """ + Load objects from OMERO, via conn.getObjects(), marshal with + omero_marshal and compare with json_objects. + omero_ids_objects can be IDs or list of omero.model objects. + """ + pids = [] + for p in omero_ids_objects: + try: + pids.append(long(p)) + except TypeError: + pids.append(p.id.val) + conn.SERVICE_OPTS.setOmeroGroup(group) + projects = conn.getObjects(dtype, pids, respect_order=True) + projects = [p._obj for p in projects] + expected = marshal_objects(projects) + assert len(json_objects) == len(expected) + for o1, o2 in zip(json_objects, expected): + assert o1 == o2 + + +class TestProjects(IWebTest): + """ + Tests querying & editing Projects + """ + + # Create a read-annotate group + @pytest.fixture(scope='function') + def group1(self): + """Returns a new read-only group.""" + return self.new_group(perms='rwra--') + + # Create a read-only group + @pytest.fixture(scope='function') + def group2(self): + """Returns a new read-only group.""" + return self.new_group(perms='rwr---') + + # Create users in the read-only group + @pytest.fixture() + def user1(self, group1, group2): + """Returns a new user in the group1 group and also add to group2""" + user = self.new_client_and_user(group=group1) + self.add_groups(user[1], [group2]) + return user + + @pytest.fixture() + def user2(self, group1): + """Returns another new user in the read-only group.""" + return self.new_client_and_user(group=group1) + + @pytest.fixture() + def project_hierarchy_user1_group1(self, user1): + """ + Returns OMERO Projects with Dataset Children with Image Children + + Note: This returns a list of mixed objects in a specified order + """ + + # Create and name all the objects + project1 = ProjectI() + project1.name = rstring('Project1') + project2 = ProjectI() + project2.name = rstring('Project2') + dataset1 = DatasetI() + dataset1.name = rstring('Dataset1') + dataset2 = DatasetI() + dataset2.name = rstring('Dataset2') + image1 = self.new_image(name='Image1') + image2 = self.new_image(name='Image2') + + # Link them together like so: + # project1 + # dataset1 + # image1 + # image2 + # dataset2 + # image2 + # project2 + # dataset2 + # image2 + project1.linkDataset(dataset1) + project1.linkDataset(dataset2) + project2.linkDataset(dataset2) + dataset1.linkImage(image1) + dataset1.linkImage(image2) + dataset2.linkImage(image2) + + to_save = [project1, project2] + projects = get_update_service(user1).saveAndReturnArray(to_save) + projects.sort(cmp_name_insensitive) + + datasets = projects[0].linkedDatasetList() + datasets.sort(cmp_name_insensitive) + + images = datasets[0].linkedImageList() + images.sort(cmp_name_insensitive) + + return projects + datasets + images + + def test_marshal_projects_not_logged_in(self): + """ + Test marshalling projects without log-in + """ + django_client = Client() + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}, + status_code=403) + assert rsp['message'] == "Not logged in" + + def test_marshal_projects_no_results(self, user1): + """ + Test marshalling projects where there are none + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}) + assert rsp['data'] == [] + + def test_marshal_projects_user(self, user1, projects_user1_group1): + """ + Test marshalling user's own projects in current group + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}) + # Reload projects with group '-1' to get same 'canLink' perms + # on owner and group permissions + assert_objects(conn, rsp['data'], projects_user1_group1) + + def test_marshal_projects_another_user(self, user1, user2, + projects_user2_group1): + """ + Test marshalling another user's projects in current group + Project is Owned by user2. We are testing user1's perms. + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}) + # user1 reloads user2's projects + assert_objects(conn, rsp['data'], projects_user2_group1) + + def test_marshal_projects_another_group(self, user1, group2, + projects_user1_group2): + """ + Test marshalling user's projects in another group + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, request_url, {}) + + # Group 1 is rwra-- Group 2 is rwr-- + # user1 reloads projects with group '-1' so that permissions on owner + # are same as owner's default group Group 1 (rwra--) instead of + # group that the data is in Group 2 (rwr--) + assert_objects(conn, rsp['data'], projects_user1_group2) + + def test_marshal_projects_all_groups(self, user1, group1, group2, + projects_user1): + """ + Test marshalling all projects for a user regardless of group and + filtering by group. + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + + # All groups + rsp = _get_response_json(django_client, request_url, {}) + assert_objects(conn, rsp['data'], projects_user1) + # Filter by group A... + gid = group1.id.val + rsp = _get_response_json(django_client, request_url, {'group': gid}) + assert_objects(conn, rsp['data'], projects_user1, group=gid) + # ...and group B + gid = group2.id.val + rsp = _get_response_json(django_client, request_url, {'group': gid}) + assert_objects(conn, rsp['data'], projects_user1, group=gid) + + def test_marshal_projects_all_users(self, user1, user2, + projects_user1_group1, + projects_user2_group1): + """ + Test marshalling all projects for a group regardless of owner + and filtering by owner. + """ + projects = projects_user1_group1 + projects_user2_group1 + projects.sort(cmp_name_insensitive) + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + + # Both users + rsp = _get_response_json(django_client, request_url, {}) + assert_objects(conn, rsp['data'], projects) + + eid = user1[1].id.val + rsp = _get_response_json(django_client, request_url, {'owner': eid}) + assert_objects(conn, rsp['data'], projects_user1_group1) + + eid = user2[1].id.val + rsp = _get_response_json(django_client, request_url, {'owner': eid}) + assert_objects(conn, rsp['data'], projects_user2_group1) + + def test_marshal_projects_pagination(self, user1, user2, + projects_user1_group1, + projects_user2_group1): + """ + Test pagination of projects + """ + projects = projects_user1_group1 + projects_user2_group1 + projects.sort(cmp_name_insensitive) + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + + # First page, just 2 projects. Page = 1 by default + limit = 2 + rsp = _get_response_json(django_client, request_url, {'limit': limit}) + assert len(rsp['data']) == limit + assert_objects(conn, rsp['data'], projects[0:limit]) + + # Check that page 2 gives next 2 projects + page = 2 + payload = {'limit': limit, 'page': page} + rsp = _get_response_json(django_client, request_url, payload) + assert_objects(conn, rsp['data'], projects[limit:limit * page]) + + def test_marshal_projects_params(self, user1, user2, + projects_user1_group1, + projects_user2_group1): + """ + Tests normalize, childCount and callback params of projects + """ + projects = projects_user1_group1 + projects_user2_group1 + projects.sort(cmp_name_insensitive) + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + request_url = reverse('api_projects', kwargs={'api_version': version}) + + # Test 'childCount' parameter + payload = {'childCount': 'true'} + rsp = _get_response_json(django_client, request_url, payload) + childCounts = [p['omero:childCount'] for p in rsp['data']] + assert childCounts == [0, 0, 0, 2, 1] + + # make dict of owners and groups to use in next test... + owners = {} + groups = {} + for p in rsp['data']: + details = p['omero:details'] + owner = details['owner'] + group = details['group'] + owners[owner['@id']] = owner + groups[group['@id']] = group + + # Test 'normalize' parameter. + payload = {'normalize': 'true'} + rsp = _get_response_json(django_client, request_url, payload) + for p in rsp['data']: + details = p['omero:details'] + owner = details['owner'] + group = details['group'] + # owner and group have @id only + assert owner.keys() == ['@id'] + assert group.keys() == ['@id'] + # check normaliszed owners and groups are same as before + rsp_owners = {} + for o in rsp['experimenters']: + rsp_owners[o['@id']] = o + rsp_groups = {} + for g in rsp['experimenterGroups']: + rsp_groups[g['@id']] = g + assert owners == rsp_owners + assert groups == rsp_groups + + def test_project_create_read(self): + """ + Tests creation by POST to /save and reading with GET of /project/:id/ + """ + django_client = self.django_root_client + group = self.ctx.groupId + version = settings.API_VERSIONS[-1] + # Need to get the Schema url to create @type + base_url = reverse('api_base', kwargs={'api_version': version}) + rsp = _get_response_json(django_client, base_url, {}) + schema_url = rsp['schema_url'] + # specify group via query params + save_url = "%s?group=%s" % (rsp['save_url'], group) + projects_url = rsp['projects_url'] + project_name = 'test_api_projects' + payload = {'Name': project_name, + '@type': schema_url + '#Project'} + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=201) + # We get the complete new Project returned + assert rsp['Name'] == project_name + project_id = rsp['@id'] + + # Read Project + project_url = "%s%s/" % (projects_url, project_id) + rsp = _get_response_json(django_client, project_url, {}) + assert rsp['@id'] == project_id + conn = BlitzGateway(client_obj=self.root) + assert_objects(conn, [rsp], [project_id]) + + def test_project_create_other_group(self, user1, projects_user1_group2): + """ + Test saving to non-default group + """ + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + # We're only using projects_user1_group2 to get group2 id + group2_id = projects_user1_group2[0].getDetails().group.id.val + # This seems to be the minimum details needed to pass group ID + group2_details = {'group': { + '@id': group2_id, + '@type': OME_SCHEMA_URL + '#ExperimenterGroup' + }, + '@type': 'TBD#Details'} + save_url = reverse('api_save', kwargs={'api_version': version}) + project_name = 'test_project_create_group' + payload = {'Name': project_name, + '@type': OME_SCHEMA_URL + '#Project'} + # Saving fails with NO group specified + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=400) + assert rsp['message'] == ("Specify Group in omero:details or " + "query parameters ?group=:id") + # Add group details and try again + payload['omero:details'] = group2_details + rsp = _csrf_post_json(django_client, save_url, payload, + status_code=201) + new_project_id = rsp['@id'] + assert rsp['omero:details']['group']['@id'] == group2_id + # Read Project + project_url = reverse('api_project', kwargs={'api_version': version, + 'pid': new_project_id}) + rsp = _get_response_json(django_client, project_url, {}) + assert rsp['omero:details']['group']['@id'] == group2_id + + def test_project_update(self, user1): + conn = get_connection(user1) + group = conn.getEventContext().groupId + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + + project = ProjectI() + project.name = rstring('test_project_update') + project.description = rstring('Test update') + project = get_update_service(user1).saveAndReturnObject(project) + + # Update Project in 2 ways... + version = settings.API_VERSIONS[-1] + project_url = reverse('api_project', kwargs={'api_version': version, + 'pid': project.id.val}) + save_url = reverse('api_save', kwargs={'api_version': version}) + # 1) Get Project, update and save back + project_json = _get_response_json(django_client, project_url, {}) + assert project_json['Name'] == 'test_project_update' + project_json['Name'] = 'new name' + rsp = _csrf_put_json(django_client, save_url, project_json) + assert rsp['@id'] == project.id.val + assert rsp['Name'] == 'new name' # Name has changed + assert rsp['Description'] == 'Test update' # No change + + # 2) Put from scratch (will delete empty fields, E.g. Description) + save_url += '?group=' + str(group) + payload = {'Name': 'updated name', + '@id': project.id.val} + # Test error message if we don't pass @type: + rsp = _csrf_put_json(django_client, save_url, payload, status_code=400) + assert rsp['message'] == 'Need to specify @type attribute' + # Add @type and try again + payload['@type'] = project_json['@type'] + rsp = _csrf_put_json(django_client, save_url, payload) + assert rsp['@id'] == project.id.val + assert rsp['Name'] == 'updated name' + assert 'Description' not in rsp + # Get project again to check update + pr_json = _get_response_json(django_client, project_url, {}) + assert pr_json['Name'] == 'updated name' + assert 'Description' not in pr_json + + def test_project_delete(self, user1): + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + + project = ProjectI() + project.name = rstring('test_project_delete') + project.description = rstring('Test update') + project = get_update_service(user1).saveAndReturnObject(project) + version = settings.API_VERSIONS[-1] + project_url = reverse('api_project', kwargs={'api_version': version, + 'pid': project.id.val}) + # Before delete, we can read + pr_json = _get_response_json(django_client, project_url, {}) + assert pr_json['Name'] == 'test_project_delete' + # Delete + _csrf_delete_response_json(django_client, project_url, {}) + # Get should now return 404 + rsp = _get_response_json(django_client, project_url, {}, + status_code=404) + assert rsp['message'] == 'Project %s not found' % project.id.val + # Delete (again) should return 404 + rsp = _csrf_delete_response_json(django_client, project_url, {}, + status_code=404) + assert rsp['message'] == 'Project %s not found' % project.id.val + save_url = reverse('api_save', kwargs={'api_version': version}) + # TODO: Try to save deleted object - should return ApiException + # see https://trello.com/c/qWNt9vLN/178-save-deleted-object + with pytest.raises(AssertionError): + rsp = _csrf_put_json(django_client, save_url, pr_json, + status_code=400) + assert rsp['message'] == 'Project %s not found' % project.id.val diff --git a/components/tools/OmeroWeb/test/integration/test_login.py b/components/tools/OmeroWeb/test/integration/test_login.py index 14d915bd495..9d103cdcd32 100644 --- a/components/tools/OmeroWeb/test/integration/test_login.py +++ b/components/tools/OmeroWeb/test/integration/test_login.py @@ -21,32 +21,67 @@ Tests webclient login """ -from weblibrary import IWebTest, _csrf_post_response +from weblibrary import IWebTest, _csrf_post_response, _get_response from django.core.urlresolvers import reverse +from django.test import Client from random import random import pytest +tag_url = reverse('load_template', kwargs={'menu': 'usertags'}) + class TestLogin(IWebTest): """ Tests login """ - @pytest.mark.parametrize("login", [['guest', 'guest'], - ['g', str(random())]]) - def test_guest_login_not_supported(self, login): + @pytest.mark.parametrize("credentials", [ + [{'username': 'guest', 'password': 'fake', 'server': 1}, + "Guest account is not supported."], + [{'username': 'nobody', 'password': '', 'server': 1}, + "This field is required."], + [{'password': 'fake'}, + "This field is required."], + [{'username': 'g', 'password': str(random()), 'server': 1}, + "please check your user name and password."] + ]) + def test_login_errors(self, credentials): """ - Test that guest login is not permitted and login as 'g' is not - confused as 'guest': - https://trello.com/c/U47AiD1R/682-weird-guest-login + Tests handling of various login errors. + E.g. missing fields, invalid credentials and guest login """ django_client = self.django_root_client request_url = reverse('weblogin') - data = {'username': login[0], 'password': login[1], 'server': 1} + data = credentials[0] + data['server'] = 1 + message = credentials[1] rsp = _csrf_post_response(django_client, request_url, data, status_code=200) - if login[0] == 'guest': - assert "Guest account is not supported." in rsp.content - else: - assert "Guest account is not supported." not in rsp.content - assert "please check your user name and password" in rsp.content + assert message in rsp.content + + def test_get_login_page(self): + """ + Simply test if a GET of the login url returns login page + """ + django_client = Client() + request_url = reverse('weblogin') + rsp = _get_response(django_client, request_url, {}, status_code=200) + assert 'OMERO.web - Login' in rsp.content + + @pytest.mark.parametrize("redirect", ['', tag_url]) + def test_login_redirect(self, redirect): + """ + Test that a successful login redirects to /webclient/ + or to specified url + """ + django_client = self.django_root_client + # redirect = reverse('load_template', kwargs={'menu': 'usertags'}) + request_url = "%s?url=%s" % (reverse('weblogin'), redirect) + data = {'username': self.ctx.userName, + 'password': self.ctx.userName, + 'server': 1} + rsp = _csrf_post_response(django_client, request_url, data, + status_code=302) + if len(redirect) == 0: + redirect = reverse('webindex') + assert rsp['Location'].endswith(redirect) diff --git a/components/tools/OmeroWeb/test/integration/weblibrary.py b/components/tools/OmeroWeb/test/integration/weblibrary.py index 38be6a0b021..cf8ff6d2d0b 100644 --- a/components/tools/OmeroWeb/test/integration/weblibrary.py +++ b/components/tools/OmeroWeb/test/integration/weblibrary.py @@ -22,6 +22,7 @@ """ import library as lib +import json from django.test import Client from django.test.client import MULTIPART_CONTENT @@ -108,6 +109,15 @@ def _post_response(django_client, request_url, data, status_code=403, **extra) +def _post_response_json(django_client, request_url, data, status_code=403, + content_type=MULTIPART_CONTENT, **extra): + rsp = _response(django_client, request_url, method='post', data=data, + status_code=status_code, content_type=content_type, + **extra) + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) + + def _csrf_post_response(django_client, request_url, data, status_code=200, content_type=MULTIPART_CONTENT): csrf_token = django_client.cookies['csrftoken'].value @@ -117,6 +127,43 @@ def _csrf_post_response(django_client, request_url, data, status_code=200, **extra) +def _csrf_post_response_json(django_client, request_url, + query_string, status_code=200): + rsp = _csrf_post_response(django_client, request_url, + query_string, status_code) + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) + + +# POST json encoded as a string +def _csrf_post_json(django_client, request_url, data, + status_code=200, content_type='application/json'): + csrf_token = django_client.cookies['csrftoken'].value + extra = {'HTTP_X_CSRFTOKEN': csrf_token} + rsp = django_client.post(request_url, json.dumps(data), + status_code=status_code, + content_type=content_type, + **extra) + print rsp + assert rsp.status_code == status_code + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) + + +# PUT json encoded as a string +def _csrf_put_json(django_client, request_url, data, + status_code=200, content_type='application/json'): + csrf_token = django_client.cookies['csrftoken'].value + extra = {'HTTP_X_CSRFTOKEN': csrf_token} + rsp = django_client.put(request_url, json.dumps(data), + status_code=status_code, content_type=content_type, + **extra) + print rsp + assert rsp.status_code == status_code + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) + + # DELETE def _delete_response(django_client, request_url, data, status_code=403, content_type=MULTIPART_CONTENT, **extra): @@ -134,11 +181,18 @@ def _csrf_delete_response(django_client, request_url, data, status_code=200, **extra) +def _csrf_delete_response_json(django_client, request_url, + data, status_code=200): + rsp = _csrf_delete_response(django_client, request_url, data, status_code) + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) + + # GET def _get_response(django_client, request_url, query_string, status_code=405): query_string = urlencode(query_string.items()) response = django_client.get('%s?%s' % (request_url, query_string)) - assert response.status_code == status_code, response + assert response.status_code == status_code return response @@ -148,3 +202,10 @@ def _csrf_get_response(django_client, request_url, query_string, query_string['csrfmiddlewaretoken'] = csrf_token return _get_response(django_client, request_url, query_string, status_code) + + +def _get_response_json(django_client, request_url, + query_string, status_code=200): + rsp = _get_response(django_client, request_url, query_string, status_code) + assert rsp.get('Content-Type') == 'application/json' + return json.loads(rsp.content) diff --git a/examples/Training/python/Json_Api/Login.py b/examples/Training/python/Json_Api/Login.py new file mode 100644 index 00000000000..ada738c3ada --- /dev/null +++ b/examples/Training/python/Json_Api/Login.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) 2016 University of Dundee & Open Microscopy Environment. +# All Rights Reserved. +# Use is subject to license terms supplied in LICENSE.txt +# + +import requests + +from Parse_OMERO_Properties import USERNAME, PASSWORD, OMERO_WEB_HOST + +session = requests.Session() + +# Start by getting supported versions from the base url... +r = session.get('%s/api/' % OMERO_WEB_HOST) +# we get a list of versions +versions = r.json()['data'] +print 'Versions', versions + +# use most recent version... +version = versions[-1] +# get the 'base' url +base_url = version['base_url'] +r = session.get(base_url) +# which lists a bunch of urls as starting points +urls = r.json() +servers_url = urls['servers_url'] +login_url = urls['login_url'] +projects_url = urls['projects_url'] +save_url = urls['save_url'] +schema_url = urls['schema_url'] + +# To login we need to get CSRF token +token_url = urls['token_url'] +token = session.get(token_url).json()['data'] +print 'CSRF token', token +# We add this to our session header +# Needed for all POST, PUT, DELETE requests +session.headers.update({'X-CSRFToken': token, + 'Referer': login_url}) + +# List the servers available to connect to +servers = session.get(servers_url).json()['data'] +print 'Servers:' +for s in servers: + print '-id:', s['id'] + print ' name:', s['server'] + print ' host:', s['host'] + print ' port:', s['port'] +# find one called 'omero' +servers = [s for s in servers if s['server'] == 'omero'] +if len(servers) < 1: + print "Found no server called 'omero'" +server = servers[0] + +# Login with username, password and token +payload = {'username': USERNAME, + 'password': PASSWORD, + # 'csrfmiddlewaretoken': token, # Using CSRFToken in header instead + 'server': server['id']} + +r = session.post(login_url, data=payload) +login_rsp = r.json() +assert r.status_code == 200 +assert login_rsp['success'] +eventContext = login_rsp['eventContext'] +print 'eventContext', eventContext +# Can get our 'default' group +groupId = eventContext['groupId'] + +# With succesful login, request.session will contain +# OMERO session details and reconnect to OMERO on +# each subsequent call... + +# List projects: +# Limit number of projects per page +payload = {'limit': 2} +data = session.get(projects_url, params=payload).json() +assert len(data['data']) < 3 +print "Projects:" +for p in data['data']: + print ' ', p['@id'], p['Name'] + +# Create a project: +projType = schema_url + '#Project' +# Need to specify target group +url = save_url + '?group=' + str(groupId) +r = session.post(url, json={'Name': 'API TEST foo', '@type': projType}) +assert r.status_code == 201 +project = r.json() +project_id = project['@id'] +print 'Created Project:', project_id, project['Name'] + +# Get project by ID +project_url = projects_url + str(project_id) + '/' +r = session.get(project_url) +project = r.json() +print project + +# Update a project +project['Name'] = 'API test updated' +r = session.put(save_url, json=project) + +# Delete a project: +r = session.delete(project_url) diff --git a/examples/Training/python/Parse_OMERO_Properties.py b/examples/Training/python/Parse_OMERO_Properties.py index 01b289da149..7efae285dc4 100755 --- a/examples/Training/python/Parse_OMERO_Properties.py +++ b/examples/Training/python/Parse_OMERO_Properties.py @@ -24,6 +24,7 @@ PORT = omeroProperties.get('omero.port', 4064) USERNAME = omeroProperties.get('omero.user') PASSWORD = omeroProperties.get('omero.pass') +OMERO_WEB_HOST = "http://localhost" projectId = omeroProperties.get('omero.projectid') datasetId = omeroProperties.get('omero.datasetid') imageId = omeroProperties.get('omero.imageid')