diff --git a/girder/girder_large_image/rest/large_image_resource.py b/girder/girder_large_image/rest/large_image_resource.py index ab544980f..cb39c054e 100644 --- a/girder/girder_large_image/rest/large_image_resource.py +++ b/girder/girder_large_image/rest/large_image_resource.py @@ -35,13 +35,14 @@ from girder.api import access from girder.api.describe import Description, autoDescribeRoute, describeRoute from girder.api.rest import Resource -from girder.constants import SortDir, TokenScope +from girder.constants import AccessType, SortDir, TokenScope from girder.exceptions import RestException from girder.models.file import File +from girder.models.folder import Folder from girder.models.item import Item from girder.models.setting import Setting from large_image import cache_util -from large_image.exceptions import TileGeneralError +from large_image.exceptions import TileGeneralError, TileSourceError from .. import constants, girder_tilesource from ..models.image_item import ImageItem @@ -255,6 +256,7 @@ def __init__(self): self.route('GET', ('histograms',), self.countHistograms) self.route('DELETE', ('histograms',), self.deleteHistograms) self.route('DELETE', ('tiles', 'incomplete'), self.deleteIncompleteTiles) + self.route('PUT', ('folder', ':id', 'tiles'), self.createLargeImages) @describeRoute( Description('Clear tile source caches to release resources and file handles.'), @@ -445,6 +447,70 @@ def _deleteCachedImages(self, spec, associatedImages=False, imageKey=None): removed += 1 return removed + @access.user(scope=TokenScope.DATA_WRITE) + @autoDescribeRoute( + Description('Create new large images for all items within a folder.') + .notes('Does not work for items with multiple files and skips over items with ' + 'existing or unfinished large images.') + .modelParam('id', 'The ID of the folder.', model=Folder, level=AccessType.WRITE, + required=True) + .param('force', 'Whether creation job(s) should be forced for each large image.', + required=False, default=False, dataType='boolean') + .param('localJobs', 'Whether the job(s) created should be local.', required=False, + default=False, dataType='boolean') + .param('recurse', 'Whether child folders should be recursed.', required=False, + default=False, dataType='boolean') + .errorResponse('ID was invalid.') + .errorResponse('Write access was denied for the folder.', 403), + ) + def createLargeImages(self, folder, params): + user = self.getCurrentUser() + createJobs = 'always' if self.boolParam('force', params, default=False) else True + return self.createImagesRecurseOption(folder=folder, createJobs=createJobs, user=user, + recurse=params.get('recurse'), + localJobs=params.get('localJobs')) + + def createImagesRecurseOption(self, folder, createJobs, user, recurse, localJobs): + result = {'childFoldersRecursed': 0, + 'itemsSkipped': 0, + 'largeImagesCreated': 0, + 'largeImagesRemovedAndRecreated': 0, + 'totalItems': 0} + if recurse: + for childFolder in Folder().childFolders(parent=folder, parentType='folder'): + result['childFoldersRecursed'] += 1 + childResult = self.createImagesRecurseOption(folder=childFolder, + createJobs=createJobs, user=user, + recurse=recurse, localJobs=localJobs) + for key in childResult: + result[key] += childResult[key] + for item in Folder().childItems(folder=folder): + result['totalItems'] += 1 + if item.get('largeImage'): + if item['largeImage'].get('expected'): + result['itemsSkipped'] += 1 + else: + try: + ImageItem().getMetadata(item) + result['itemsSkipped'] += 1 + continue + except (TileSourceError, KeyError): + previousFileId = item['largeImage'].get('originalId', + item['largeImage']['fileId']) + ImageItem().delete(item) + ImageItem().createImageItem(item, File().load(user=user, id=previousFileId), + createJob=createJobs, localJob=localJobs) + result['largeImagesRemovedAndRecreated'] += 1 + else: + files = list(Item().childFiles(item=item, limit=2)) + if len(files) == 1: + ImageItem().createImageItem(item, files[0], createJob=createJobs, + localJob=localJobs) + result['largeImagesCreated'] += 1 + else: + result['itemsSkipped'] += 1 + return result + @describeRoute( Description('Remove large images from items where the large image job ' 'incomplete.') diff --git a/girder/test_girder/test_large_image.py b/girder/test_girder/test_large_image.py index fb9ab6a39..c6072ace1 100644 --- a/girder/test_girder/test_large_image.py +++ b/girder/test_girder/test_large_image.py @@ -237,6 +237,37 @@ def testThumbnailFileJob(server, admin, user, fsAssetstore): Setting().set(constants.PluginSettings.LARGE_IMAGE_MAX_THUMBNAIL_FILES, 0) +@pytest.mark.usefixtures('unbindLargeImage') +@pytest.mark.plugin('large_image') +def testFolderCreateImages(server, admin, user, fsAssetstore): + file = utilities.uploadExternalFile('sample_image.ptif', admin, fsAssetstore) + itemId = file['itemId'] + item = Item().load(itemId, user=admin) + folderId = str(item['folderId']) + # Remove the large image from this item + ImageItem().delete(item) + # Ask to make all items in this folder large images + resp = server.request( + method='PUT', path=f'/large_image/folder/{folderId}/tiles', user=admin) + assert utilities.respStatus(resp) == 200 + assert resp.json['largeImagesCreated'] == 1 + item = Item().load(itemId, user=admin) + # Check that this item became a large image again + assert 'largeImage' in item + # Hitting the endpoint again should skip the item + resp = server.request( + method='PUT', path=f'/large_image/folder/{folderId}/tiles', user=admin) + assert utilities.respStatus(resp) == 200 + assert resp.json['itemsSkipped'] == 1 + # If the item's source isn't working, it should be recreated. + item['largeImage']['sourceName'] = 'unknown' + Item().updateItem(item) + resp = server.request( + method='PUT', path=f'/large_image/folder/{folderId}/tiles', user=admin) + assert utilities.respStatus(resp) == 200 + assert resp.json['largeImagesRemovedAndRecreated'] == 1 + + @pytest.mark.singular() @pytest.mark.usefixtures('unbindLargeImage') @pytest.mark.plugin('large_image')