diff --git a/components/tools/OmeroPy/src/omero/gateway/__init__.py b/components/tools/OmeroPy/src/omero/gateway/__init__.py index a6fe2213217..ea682010700 100644 --- a/components/tools/OmeroPy/src/omero/gateway/__init__.py +++ b/components/tools/OmeroPy/src/omero/gateway/__init__.py @@ -141,6 +141,23 @@ def fileread_gen(fin, fsize, bufsize): fin.close() +def getPixelsQuery(imageName): + """Helper for building Query for Images or Wells & Images""" + return (' left outer join fetch %s.pixels as pixels' + ' left outer join fetch pixels.pixelsType' % imageName) + + +def getChannelsQuery(): + """Helper for building Query for Images or Wells & Images""" + return (' join fetch pixels.channels as channels' + ' join fetch channels.logicalChannel as logicalChannel' + ' left outer join fetch ' + ' logicalChannel.photometricInterpretation' + ' left outer join fetch logicalChannel.illumination' + ' left outer join fetch logicalChannel.mode' + ' left outer join fetch logicalChannel.contrastMethod') + + class OmeroRestrictionWrapper (object): def canDownload(self): @@ -3096,6 +3113,7 @@ def buildQuery(self, obj_type, ids=None, params=None, attributes=None, :param opts: Dict of additional options for filtering or defining extra data to load. offset, limit and owner for all objects. + Also 'order_by': 'obj.name' to order results. Additional opts handled by _getQueryString() E.g. 'childCount', or filter Dataset by 'project' :return: (query, params, wrapper) @@ -3171,9 +3189,9 @@ def buildQuery(self, obj_type, ids=None, params=None, attributes=None, if clauses: query += " where " + (" and ".join(clauses)) - # Order by... + # Order by... e.g. 'lower(obj.name)' or 'obj.column, obj.row' for wells if order_by is not None: - query += " order by lower(obj.%s), obj.id" % order_by + query += " order by %s, obj.id" % order_by return (query, baseParams, wrapper) @@ -6053,6 +6071,42 @@ def __reset__(self): """ self._childcache = None + @classmethod + def _getQueryString(cls, opts=None): + """ + Extend base query to handle filtering of Wells by Plate. + Returns a tuple of (query, clauses, params). + Supported opts: 'plate': to filter by Plate + 'load_images': to load wellSamples and images + + :param opts: Dictionary of optional parameters. + :return: Tuple of string, list, ParametersI + """ + query, clauses, params = super( + _WellWrapper, cls)._getQueryString(opts) + if opts is not None and 'plate' in opts: + clauses.append('obj.plate.id = :pid') + params.add('pid', rlong(opts['plate'])) + load_images = False + load_pixels = False + load_channels = False + if opts is not None: + load_images = opts.get('load_images') + load_pixels = opts.get('load_pixels') + load_channels = opts.get('load_channels') + if load_images or load_pixels or load_channels: + # NB: Using left outer join, we may get Wells with no Images + query += " left outer join fetch obj.wellSamples as wellSamples"\ + " left outer join fetch wellSamples.image as image"\ + " left outer join fetch wellSamples.plateAcquisition"\ + " as plateAcquisition" + if load_pixels or load_channels: + query += getPixelsQuery("image") + if load_channels: + query += getChannelsQuery() + + return (query, clauses, params) + def __loadedHotSwap__(self): query = ("select well from Well as well " "join fetch well.details.creationEvent " @@ -7140,15 +7194,9 @@ def _getQueryString(cls, opts=None): orphaned = opts.get('orphaned') if load_pixels or load_channels: # We use 'left outer join', since we still want images if no pixels - query += ' left outer join fetch obj.pixels pixels' \ - ' left outer join fetch pixels.pixelsType' + query += getPixelsQuery("obj") if load_channels: - query += ' join fetch pixels.channels as channels' \ - ' join fetch channels.logicalChannel as logicalChannel' \ - ' left outer join fetch logicalChannel.photometricInterpretation' \ - ' left outer join fetch logicalChannel.illumination' \ - ' left outer join fetch logicalChannel.mode' \ - ' left outer join fetch logicalChannel.contrastMethod' + query += getChannelsQuery() if orphaned: clauses.append( """ diff --git a/components/tools/OmeroWeb/omeroweb/api/urls.py b/components/tools/OmeroWeb/omeroweb/api/urls.py index f2a77063fd3..f320700b2bd 100644 --- a/components/tools/OmeroWeb/omeroweb/api/urls.py +++ b/components/tools/OmeroWeb/omeroweb/api/urls.py @@ -169,6 +169,30 @@ Plate url to GET or DELETE a single Plate """ +api_wells = url(r'^v(?P%s)/m/wells/$' % versions, + views.WellsView.as_view(), + name='api_wells') +""" +GET all wells, using omero-marshal to generate json +""" + +api_plate_wells = url( + r'^v(?P%s)/m/plates/' + '(?P[0-9]+)/wells/$' % versions, + views.WellsView.as_view(), + name='api_plate_wells') +""" +GET Wells in Plate, using omero-marshal to generate json +""" + +api_well = url( + r'^v(?P%s)/m/wells/(?P[0-9]+)/$' % versions, + views.WellView.as_view(), + name='api_well') +""" +Well url to GET or DELETE a single Well +""" + urlpatterns = patterns( '', api_versions, @@ -190,4 +214,7 @@ api_plates, api_screen_plates, api_plate, + api_wells, + api_plate_wells, + api_well, ) diff --git a/components/tools/OmeroWeb/omeroweb/api/views.py b/components/tools/OmeroWeb/omeroweb/api/views.py index b1b8bcae317..e2530f9555a 100644 --- a/components/tools/OmeroWeb/omeroweb/api/views.py +++ b/components/tools/OmeroWeb/omeroweb/api/views.py @@ -123,7 +123,7 @@ def dispatch(self, *args, **kwargs): """Wrap other methods to add decorators.""" return super(ApiView, self).dispatch(*args, **kwargs) - def add_data(self, marshalled, request, **kwargs): + def add_data(self, marshalled, request, urls=None, **kwargs): """ Post-process marshalled object to add any extra data. @@ -133,15 +133,16 @@ def add_data(self, marshalled, request, **kwargs): """ object_id = marshalled['@id'] version = kwargs['api_version'] - for key, args in self.urls.items(): - name = args['name'] - kwargs = args['kwargs'].copy() - # If kwargs has 'OBJECT_ID' placeholder, we replace with id - for k, v in kwargs.items(): - if v == 'OBJECT_ID': - kwargs[k] = object_id - url = build_url(request, name, version, **kwargs) - marshalled[key] = url + if urls is not None: + for key, args in urls.items(): + name = args['name'] + kwargs = args['kwargs'].copy() + # If kwargs has 'OBJECT_ID' placeholder, we replace with id + for k, v in kwargs.items(): + if v == 'OBJECT_ID': + kwargs[k] = object_id + url = build_url(request, name, version, **kwargs) + marshalled[key] = url return marshalled @@ -161,7 +162,7 @@ def get(self, request, object_id, conn=None, **kwargs): object_id)) encoder = get_encoder(obj._obj.__class__) marshalled = encoder.encode(obj._obj) - self.add_data(marshalled, request, **kwargs) + self.add_data(marshalled, request, self.urls, **kwargs) return marshalled def delete(self, request, object_id, conn=None, **kwargs): @@ -214,7 +215,6 @@ class ImageView(ObjectView): def get_opts(self, request): """Add support for load_pixels and load_channels.""" opts = super(ImageView, self).get_opts(request) - opts['orphaned'] = request.GET.get('orphaned', False) == 'true' # for single image, we always load channels opts['load_channels'] = True return opts @@ -237,6 +237,40 @@ class PlateView(ObjectView): OMERO_TYPE = 'Plate' + # Urls to add to marshalled object. See ProjectsView for more details + urls = { + 'url:wells': {'name': 'api_plate_wells', + 'kwargs': {'plate_id': 'OBJECT_ID'}} + } + + +class WellView(ObjectView): + """Handle access to an individual Well to GET or DELETE it.""" + + OMERO_TYPE = 'Well' + + def get_opts(self, request): + """Add support for load_images.""" + opts = super(WellView, self).get_opts(request) + # for single well, we load images with pixels + opts['load_pixels'] = True + return opts + + def add_data(self, marshalled, request, urls=None, **kwargs): + """Add 'url:image' to any 'Image' in 'WellSamples'.""" + marshalled = super(WellView, self).add_data(marshalled, request, + urls=urls, **kwargs) + image_urls = { + 'url:image': {'name': 'api_image', + 'kwargs': {'object_id': 'OBJECT_ID'}}, + } + if 'WellSamples' in marshalled: + # For each WellSample, add image urls to Image + for ws in marshalled['WellSamples']: + if 'Image' in ws: + self.add_data(ws['Image'], request, image_urls, **kwargs) + return marshalled + class ObjectsView(ApiView): """Base class for listing objects.""" @@ -258,7 +292,6 @@ def get_opts(self, request, **kwargs): 'owner': owner, 'orphaned': orphaned, 'child_count': child_count, - 'order_by': 'name', # NB: will break if object has no name } return opts @@ -271,7 +304,7 @@ def get(self, request, conn=None, **kwargs): marshalled = query_objects(conn, self.OMERO_TYPE, group, opts, normalize) for m in marshalled['data']: - self.add_data(m, request, **kwargs) + self.add_data(m, request, self.urls, **kwargs) return marshalled @@ -280,6 +313,12 @@ class ProjectsView(ObjectsView): OMERO_TYPE = 'Project' + def get_opts(self, request, **kwargs): + """Add extra parameters to the opts dict.""" + opts = super(ProjectsView, self).get_opts(request, **kwargs) + opts['order_by'] = 'lower(obj.name)' + return opts + # To add a url to marshalled object add to this dict # 'name' is url name, kwargs are passed to reverse() # If any kwargs values are 'OBJECT_ID' then this placeholder will be @@ -298,8 +337,9 @@ class DatasetsView(ObjectsView): OMERO_TYPE = 'Dataset' def get_opts(self, request, **kwargs): - """Add filtering by 'project' to the opts dict.""" + """Add extra parameters to the opts dict.""" opts = super(DatasetsView, self).get_opts(request, **kwargs) + opts['order_by'] = 'lower(obj.name)' # at /projects/:project_id/datasets/ we have 'project_id' in kwargs if 'project_id' in kwargs: opts['project'] = long(kwargs['project_id']) @@ -324,6 +364,12 @@ class ScreensView(ObjectsView): OMERO_TYPE = 'Screen' + def get_opts(self, request, **kwargs): + """Add extra parameters to the opts dict.""" + opts = super(ScreensView, self).get_opts(request, **kwargs) + opts['order_by'] = 'lower(obj.name)' + return opts + # Urls to add to marshalled object. See ProjectsView for more details urls = { 'url:plates': {'name': 'api_screen_plates', @@ -339,8 +385,9 @@ class PlatesView(ObjectsView): OMERO_TYPE = 'Plate' def get_opts(self, request, **kwargs): - """Add filtering by 'screen' to the opts dict.""" + """Add extra parameters to the opts dict.""" opts = super(PlatesView, self).get_opts(request, **kwargs) + opts['order_by'] = 'lower(obj.name)' # at /screens/:screen_id/plates/ we have 'screen_id' in kwargs if 'screen_id' in kwargs: opts['screen'] = long(kwargs['screen_id']) @@ -353,6 +400,8 @@ def get_opts(self, request, **kwargs): # Urls to add to marshalled object. See ProjectsView for more details urls = { + 'url:wells': {'name': 'api_plate_wells', + 'kwargs': {'plate_id': 'OBJECT_ID'}}, 'url:plate': {'name': 'api_plate', 'kwargs': {'object_id': 'OBJECT_ID'}} } @@ -370,8 +419,9 @@ class ImagesView(ObjectsView): } def get_opts(self, request, **kwargs): - """Add filtering by 'dataset' and other params to the opts dict.""" + """Add extra parameters to the opts dict.""" opts = super(ImagesView, self).get_opts(request, **kwargs) + opts['order_by'] = 'lower(obj.name)' # at /datasets/:dataset_id/images/ we have 'dataset_id' in kwargs if 'dataset_id' in kwargs: opts['dataset'] = long(kwargs['dataset_id']) @@ -385,6 +435,49 @@ def get_opts(self, request, **kwargs): return opts +class WellsView(ObjectsView): + """Handles GET for /wells/ to list available Images.""" + + OMERO_TYPE = 'Well' + + # Urls to add to marshalled object. See ProjectsView for more details + urls = { + 'url:well': {'name': 'api_well', + 'kwargs': {'object_id': 'OBJECT_ID'}}, + } + + def get_opts(self, request, **kwargs): + """Add extra parameters to the opts dict.""" + opts = super(WellsView, self).get_opts(request, **kwargs) + opts['order_by'] = 'obj.column, obj.row' + # at /plates/:plate_id/wells/ we have 'plate_id' in kwargs + if 'plate_id' in kwargs: + opts['plate'] = long(kwargs['plate_id']) + else: + # filter by query /wells/?plate=:id + plate = getIntOrDefault(request, 'plate', None) + if plate is not None: + opts['plate'] = plate + # Listing Wells, load Images + opts['load_images'] = True + return opts + + def add_data(self, marshalled, request, urls=None, **kwargs): + """Add 'url:image' to any 'Image' in 'WellSamples'.""" + marshalled = super(WellsView, self).add_data(marshalled, request, + urls=urls, **kwargs) + image_urls = { + 'url:image': {'name': 'api_image', + 'kwargs': {'object_id': 'OBJECT_ID'}}, + } + if 'WellSamples' in marshalled: + # For each WellSample, add image urls to Image + for ws in marshalled['WellSamples']: + if 'Image' in ws: + self.add_data(ws['Image'], request, image_urls, **kwargs) + return marshalled + + class SaveView(View): """ This view provides 'Save' functionality for all types of objects. diff --git a/components/tools/OmeroWeb/test/integration/test_api_containers.py b/components/tools/OmeroWeb/test/integration/test_api_containers.py index eb4a19d73a9..a8d011dcb40 100644 --- a/components/tools/OmeroWeb/test/integration/test_api_containers.py +++ b/components/tools/OmeroWeb/test/integration/test_api_containers.py @@ -24,30 +24,17 @@ from django.core.urlresolvers import reverse from django.conf import settings import pytest +from test_api_projects import cmp_name_insensitive, get_update_service, \ + get_connection, marshal_objects from omero.gateway import BlitzGateway -from omero_marshal import get_encoder -from omero.model import DatasetI, ProjectI, ScreenI, PlateI, ImageI -from omero.rtypes import rstring, unwrap - - -def get_update_service(user): - """Get the update_service for the given user's client.""" - return user[0].getSession().getUpdateService() - - -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 - - -def cmp_name_insensitive(x, y): - """Case-insensitive name comparator.""" - return cmp(unwrap(x.name).lower(), unwrap(y.name).lower()) +from omero.model import DatasetI, \ + ImageI, \ + PlateI, \ + ProjectI, \ + ScreenI, \ + WellI, \ + WellSampleI +from omero.rtypes import rstring, rint def build_url(client, url_name, url_kwargs): @@ -60,17 +47,20 @@ def build_url(client, url_name, url_kwargs): return url -def marshal_objects(objects): - """Marshal objects using omero_marshal.""" - expected = [] - for obj in objects: - encoder = get_encoder(obj.__class__) - expected.append(encoder.encode(obj)) +def add_image_urls(expected, client): + """Add urls to expected Images within Well dict.""" + version = settings.API_VERSIONS[-1] + if 'WellSamples' in expected: + for ws in expected['WellSamples']: + image_id = ws['Image']['@id'] + url = build_url(client, 'api_image', {'api_version': version, + 'object_id': image_id}) + ws['Image']['url:image'] = url return expected def assert_objects(conn, json_objects, omero_ids_objects, dtype="Project", - group='-1', extra=None, opts=None): + group='-1', extra=None, opts=None, client=None): """ Load objects from OMERO, via conn.getObjects(). @@ -98,6 +88,8 @@ def assert_objects(conn, json_objects, omero_ids_objects, dtype="Project", for key in o1.keys(): if key.startswith('url:') and key not in o2: del(o1[key]) + # add urls to any 'Image' in expected 'Wells' dict + add_image_urls(o2, client) assert o1 == o2 @@ -156,6 +148,22 @@ def screen_plates(self, user1): screen = get_update_service(user1).saveAndReturnObject(screen) plate = get_update_service(user1).saveAndReturnObject(plate) + + # Add well to first plate + plates = screen.linkedPlateList() + plates.sort(cmp_name_insensitive) + plate_id = plates[0].id.val + well = WellI() + well.column = rint(0) + well.row = rint(0) + well.plate = PlateI(plate_id, False) + image = self.create_test_image( + size_x=5, size_y=5, session=user1[0].getSession()) + ws = WellSampleI() + ws.image = ImageI(image.id, False) + ws.well = well + well.addWellSample(ws) + well = get_update_service(user1).saveAndReturnObject(well) return screen, plate @pytest.fixture() @@ -340,12 +348,26 @@ def test_spw_urls(self, user1, screen_plates): 'url:plate': build_url(client, 'api_plate', {'api_version': version, 'object_id': p.id.val}), + 'url:wells': build_url(client, 'api_plate_wells', + {'api_version': version, + 'plate_id': p.id.val}) }) assert_objects(conn, plates_json, plates, dtype='Plate', extra=extra) # View single plate rsp = _get_response_json(client, plates_json[0]['url:plate'], {}) assert_objects(conn, [rsp], plates[0:1], dtype='Plate') + # List wells of first plate + wells_url = plates_json[0]['url:wells'] + rsp = _get_response_json(client, wells_url, {}) + wells_json = rsp['data'] + well_id = wells_json[0]['@id'] + extra = [{'url:well': build_url(client, 'api_well', + {'api_version': version, 'object_id': well_id})} + ] + assert_objects(conn, wells_json, [well_id], dtype='Well', + extra=extra, opts={'load_images': True}, client=client) + def test_pdi_urls(self, user1, project_datasets): """Test browsing via urls in json /api/->PDI.""" conn = get_connection(user1) diff --git a/components/tools/OmeroWeb/test/integration/test_api_errors.py b/components/tools/OmeroWeb/test/integration/test_api_errors.py index a5de350f209..7c9a5cfdaa0 100644 --- a/components/tools/OmeroWeb/test/integration/test_api_errors.py +++ b/components/tools/OmeroWeb/test/integration/test_api_errors.py @@ -17,60 +17,44 @@ # 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 -""" +"""Tests querying & editing Projects with webgateway json api.""" from omeroweb.testlib 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 test_api_projects import get_connection 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 - """ + """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 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 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""" + """Return 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 """ + """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}) @@ -82,7 +66,7 @@ def test_save_post_no_id(self): "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 """ + """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}) @@ -93,7 +77,7 @@ def test_save_put_id(self): "No '@id' attribute. Use POST to create new objects") def test_marshal_type(self): - """ If no decoder found for @type, get suitable message""" + """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}) @@ -106,6 +90,7 @@ def test_marshal_type(self): 'No decoder found for type: %s' % objType) def test_marshal_validation(self): + """Test that we get expected error with invalid @type in json.""" django_client = self.django_root_client version = settings.API_VERSIONS[-1] save_url = reverse('api_save', kwargs={'api_version': version}) @@ -120,6 +105,7 @@ def test_marshal_validation(self): 'Traceback (most recent call last):') def test_security_violation(self, group_B, user_A): + """Test saving to incorrect group.""" conn = get_connection(user_A) groupAid = conn.getEventContext().groupId userName = conn.getUser().getName() @@ -144,21 +130,8 @@ def test_security_violation(self, group_B, user_A): 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): + """Test handling when we try to save something invalid.""" conn = get_connection(user_A) group = conn.getEventContext().groupId userName = conn.getUser().getName() @@ -187,7 +160,9 @@ def test_validation_exception(self, user_A): def test_project_validation(self, user_A): """ - This test illustrates the ValidationException we see when + Test to demonstrate details bug on encode->decode. + + 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 diff --git a/components/tools/OmeroWeb/test/integration/test_api_images.py b/components/tools/OmeroWeb/test/integration/test_api_images.py index e0fadf2a9d6..067ccc62528 100644 --- a/components/tools/OmeroWeb/test/integration/test_api_images.py +++ b/components/tools/OmeroWeb/test/integration/test_api_images.py @@ -23,47 +23,18 @@ from django.core.urlresolvers import reverse from django.conf import settings import pytest -from omero.gateway import BlitzGateway -from omero_marshal import get_encoder +from test_api_projects import cmp_name_insensitive, get_update_service, \ + get_connection, marshal_objects from omero.model import DatasetI, ImageI -from omero.rtypes import rstring, unwrap +from omero.rtypes import rstring import json -def get_update_service(user): - """Get the update_service for the given user's client.""" - return user[0].getSession().getUpdateService() - - def get_query_service(user): """Get the query_service for the given user's client.""" return user[0].getSession().getQueryService() -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 - - -def cmp_name_insensitive(x, y): - """Case-insensitive name comparator.""" - return cmp(unwrap(x.name).lower(), unwrap(y.name).lower()) - - -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', extra=None, opts=None): """ diff --git a/components/tools/OmeroWeb/test/integration/test_api_wells.py b/components/tools/OmeroWeb/test/integration/test_api_wells.py new file mode 100644 index 00000000000..b301a32786c --- /dev/null +++ b/components/tools/OmeroWeb/test/integration/test_api_wells.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2017 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 Wells with web json api.""" + +from omeroweb.testlib import IWebTest, _get_response_json +from django.core.urlresolvers import reverse +from django.conf import settings +import pytest +from test_api_projects import get_update_service, \ + get_connection, marshal_objects +from omero.model import ImageI, \ + LengthI, \ + PlateAcquisitionI, \ + PlateI, \ + WellI, \ + WellSampleI +from omero.model.enums import UnitsLength +from omero.rtypes import rstring, rint, unwrap, rtime +import json + + +def get_query_service(user): + """Get the query_service for the given user's client.""" + return user[0].getSession().getQueryService() + + +def cmp_column_row(x, y): + """Sort wells by row, then column.""" + sort_by_column = cmp(unwrap(x.column), unwrap(y.column)) + if sort_by_column == 0: + return cmp(unwrap(x.row), unwrap(y.row)) + return sort_by_column + + +def remove_urls(marshalled): + """Traverse a dict (Well) removing 'url:' values.""" + for key, val in marshalled.items(): + if key.startswith('url:'): + del(marshalled[key]) + # We only traverse paths where we know urls are + elif key == 'Image': + remove_urls(val) + elif key == 'WellSamples': + for i in val: + remove_urls(i) + + +def assert_objects(conn, json_objects, omero_ids_objects, dtype="Project", + group='-1', extra=None, opts=None): + """ + 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. + + @param: extra List of dicts containing expected extra json data + e.g. {'omero:childCount': 1} + """ + pids = [] + for p in omero_ids_objects: + try: + pids.append(long(p)) + except TypeError: + pids.append(p.id.val) + conn.SERVICE_OPTS.setOmeroGroup(group) + objs = conn.getObjects(dtype, pids, respect_order=True, opts=opts) + objs = [p._obj for p in objs] + expected = marshal_objects(objs) + assert len(json_objects) == len(expected) + for i, o1, o2 in zip(range(len(expected)), json_objects, expected): + if extra is not None and i < len(extra): + o2.update(extra[i]) + # We dump to json and re-load (same as test data). This means that + # unicode has been handled in same way, e.g. Pixel size symbols. + o2 = json.loads(json.dumps(o2)) + # remove any urls from json (tested elsewhere) + remove_urls(o1) + + assert o1 == o2 + + +class TestWells(IWebTest): + """Tests querying Wells.""" + + @pytest.fixture() + def user1(self): + """Return a new user in a read-annotate group.""" + group = self.new_group(perms='rwra--') + return self.new_client_and_user(group=group) + + def create_plate_wells(self, user1, rows, cols, with_plate_acq=True): + """Return Plate with Wells.""" + updateService = get_update_service(user1) + plate = PlateI() + plate.name = rstring('plate') + plate = updateService.saveAndReturnObject(plate) + + # Single PlateAcquisition for plate + if with_plate_acq: + plate_acq = PlateAcquisitionI() + plate_acq.name = rstring('plateacquisition') + plate_acq.description = rstring('plateacquisition_description') + plate_acq.maximumFieldCount = rint(3) + plate_acq.startTime = rtime(1L) + plate_acq.endTime = rtime(2L) + plate_acq.plate = PlateI(plate.id.val, False) + plate_acq = updateService.saveAndReturnObject(plate_acq) + + # Create Wells for plate + for row in range(rows): + for col in range(cols): + # create Well + well = WellI() + well.column = rint(col) + well.row = rint(row) + well.plate = PlateI(plate.id.val, False) + # Only wells in first Column have well-samples etc. + if col == 0: + # Have 3 images/well-samples in these wells + for i in range(3): + image = self.create_test_image( + size_x=5, size_y=5, session=user1[0].getSession()) + ws = WellSampleI() + ws.image = ImageI(image.id, False) + ws.well = well + ws.posX = LengthI(i * 10, UnitsLength.REFERENCEFRAME) + ws.posY = LengthI(i, UnitsLength.REFERENCEFRAME) + if with_plate_acq: + ws.setPlateAcquisition( + PlateAcquisitionI(plate_acq.id.val, False)) + well.addWellSample(ws) + updateService.saveObject(well) + return plate + + @pytest.fixture() + def small_plate(self, user1): + """ + Create a small plate with 1 row and 2 columns. + + Two wells are created, but only the first has any Images (3). + """ + return self.create_plate_wells(user1, 1, 2, with_plate_acq=False) + + @pytest.fixture() + def bigger_plate(self, user1): + """ + Create a bigger plate with 2 rows and 3 columns. + + Six wells are created, but only wells in the first + column have any Images (3 in each Well). + """ + return self.create_plate_wells(user1, 2, 3) + + def test_plate_wells(self, user1, small_plate, bigger_plate): + """Test listing of Wells in a Plate.""" + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + + wells_url = reverse('api_wells', kwargs={'api_version': version}) + + # List ALL Wells in both plates + rsp = _get_response_json(django_client, wells_url, {}) + assert len(rsp['data']) == 8 + + # Filter Wells by Plate + for plate, with_acq, well_count in zip([small_plate, bigger_plate], + [False, True], + [2, 6]): + # Use Blitz Plates for listing Wells etc. + plate_wrapper = conn.getObject('Plate', plate.id.val) + wells = [w._obj for w in plate_wrapper.listChildren()] + wells.sort(cmp_column_row) + payload = {'plate': plate.id.val} + rsp = _get_response_json(django_client, wells_url, payload) + # Manual check that Images are loaded but Pixels are not + assert len(rsp['data']) == well_count + well_sample = rsp['data'][0]['WellSamples'][0] + assert 'Image' in well_sample + assert ('PlateAcquisition' in well_sample) == with_acq + assert 'Pixels' not in well_sample['Image'] + + assert_objects(conn, rsp['data'], wells, dtype='Well', + opts={'load_images': True}) + + def test_well(self, user1, small_plate): + """Test loading a single Well, with or without WellSamples.""" + conn = get_connection(user1) + user_name = conn.getUser().getName() + django_client = self.new_django_client(user_name, user_name) + version = settings.API_VERSIONS[-1] + + small_plate = conn.getObject('Plate', small_plate.id.val) + wells = [w._obj for w in small_plate.listChildren()] + wells.sort(cmp_column_row) + + # plate has 2 wells. First has WellSamples, other doesn't + for well, has_image in zip(wells, [True, False]): + well_url = reverse('api_well', kwargs={'api_version': version, + 'object_id': well.id.val}) + rsp = _get_response_json(django_client, well_url, {}) + # Manually check for image and Pixels loaded + assert ('WellSamples' in rsp) == has_image + if has_image: + assert len(rsp['WellSamples']) == 3 + assert 'Pixels' in rsp['WellSamples'][0]['Image'] + assert_objects(conn, [rsp], [well], dtype='Well', + opts={'load_pixels': True})