girder-local-checkout 5.0.23.dev7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- girder_local_checkout-5.0.23.dev7/MANIFEST.in +3 -0
- girder_local_checkout-5.0.23.dev7/PKG-INFO +41 -0
- girder_local_checkout-5.0.23.dev7/README.rst +13 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/__init__.py +120 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/girder_worker_plugin.py +9 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/models.py +106 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/rest.py +217 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/settings.py +67 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/tasks.py +34 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/utils.py +495 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/web_client/dist/girder-plugin-local-checkout.umd.cjs +16 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout/web_client/dist/style.css +2 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/PKG-INFO +41 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/SOURCES.txt +25 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/dependency_links.txt +1 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/entry_points.txt +5 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/not-zip-safe +1 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/requires.txt +2 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/scm_file_list.json +30 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/scm_version.json +8 -0
- girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/top_level.txt +1 -0
- girder_local_checkout-5.0.23.dev7/plugin.cmake +1 -0
- girder_local_checkout-5.0.23.dev7/plugin_tests/__init__.py +0 -0
- girder_local_checkout-5.0.23.dev7/plugin_tests/test_local_checkout.py +499 -0
- girder_local_checkout-5.0.23.dev7/pyproject.toml +6 -0
- girder_local_checkout-5.0.23.dev7/setup.cfg +4 -0
- girder_local_checkout-5.0.23.dev7/setup.py +66 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: girder-local-checkout
|
|
3
|
+
Version: 5.0.23.dev7
|
|
4
|
+
Summary: Temporarily copy S3 assetstore data to a filesystem assetstore.
|
|
5
|
+
Home-page: https://github.com/girder/girder/tree/master/plugins/local_checkout
|
|
6
|
+
Author: Kitware, Inc.
|
|
7
|
+
Author-email: kitware@kitware.com
|
|
8
|
+
License: Apache 2.0
|
|
9
|
+
Keywords: girder-plugin,local_checkout
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Natural Language :: English
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/x-rst
|
|
15
|
+
Requires-Dist: girder-jobs>=3.0.3
|
|
16
|
+
Requires-Dist: girder>=3.1.23
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: classifier
|
|
20
|
+
Dynamic: description
|
|
21
|
+
Dynamic: description-content-type
|
|
22
|
+
Dynamic: home-page
|
|
23
|
+
Dynamic: keywords
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
local-checkout
|
|
30
|
+
==============
|
|
31
|
+
|
|
32
|
+
A Girder plugin that lets users temporarily "checkout" the contents of a
|
|
33
|
+
folder from an S3 assetstore to a filesystem assetstore.
|
|
34
|
+
|
|
35
|
+
Files on S3 assetstores are copied to a filesystem assetstore for a chosen
|
|
36
|
+
number of days; while checked out, Girder serves the local copy. When a
|
|
37
|
+
checkout expires, or when the filesystem assetstore runs short of space, the
|
|
38
|
+
local copies are removed and the files once again refer to the S3 data they
|
|
39
|
+
came from.
|
|
40
|
+
|
|
41
|
+
See the Girder documentation for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
local-checkout
|
|
2
|
+
==============
|
|
3
|
+
|
|
4
|
+
A Girder plugin that lets users temporarily "checkout" the contents of a
|
|
5
|
+
folder from an S3 assetstore to a filesystem assetstore.
|
|
6
|
+
|
|
7
|
+
Files on S3 assetstores are copied to a filesystem assetstore for a chosen
|
|
8
|
+
number of days; while checked out, Girder serves the local copy. When a
|
|
9
|
+
checkout expires, or when the filesystem assetstore runs short of space, the
|
|
10
|
+
local copies are removed and the files once again refer to the S3 data they
|
|
11
|
+
came from.
|
|
12
|
+
|
|
13
|
+
See the Girder documentation for details.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from girder import events, plugin
|
|
4
|
+
from girder.exceptions import ValidationException
|
|
5
|
+
from girder.utility.filesystem_assetstore_adapter import FilesystemAssetstoreAdapter
|
|
6
|
+
from girder.utility.model_importer import ModelImporter
|
|
7
|
+
|
|
8
|
+
from . import rest, utils
|
|
9
|
+
from .models import Checkout, CheckoutFile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _wrapUploadMethods():
|
|
13
|
+
"""
|
|
14
|
+
Make the filesystem assetstore adapter release local copies rather than
|
|
15
|
+
reject an upload when it is short of space.
|
|
16
|
+
"""
|
|
17
|
+
if getattr(FilesystemAssetstoreAdapter, '_localCheckoutWrapped', False):
|
|
18
|
+
return
|
|
19
|
+
for name in ('initUpload', 'uploadChunk'):
|
|
20
|
+
base = getattr(FilesystemAssetstoreAdapter, name)
|
|
21
|
+
|
|
22
|
+
def wrapper(self, upload, *args, _base=base, **kwargs):
|
|
23
|
+
utils.makeRoomForUpload(self, upload)
|
|
24
|
+
return _base(self, upload, *args, **kwargs)
|
|
25
|
+
|
|
26
|
+
wrapper.__name__ = name
|
|
27
|
+
wrapper.__doc__ = base.__doc__
|
|
28
|
+
setattr(FilesystemAssetstoreAdapter, name, wrapper)
|
|
29
|
+
FilesystemAssetstoreAdapter._localCheckoutWrapped = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _onFileRemove(event):
|
|
33
|
+
"""
|
|
34
|
+
When a checked-out file is deleted, core removes the local copy; we must
|
|
35
|
+
also remove the original data it came from.
|
|
36
|
+
"""
|
|
37
|
+
record = CheckoutFile().findByFile(event.info)
|
|
38
|
+
if record is None:
|
|
39
|
+
return
|
|
40
|
+
utils.deleteOriginal(record)
|
|
41
|
+
CheckoutFile().remove(record)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _onFileReplaced(event):
|
|
45
|
+
"""
|
|
46
|
+
When the contents of a checked-out file are replaced (including being
|
|
47
|
+
moved to another assetstore), core has already discarded the local copy,
|
|
48
|
+
so the original data is no longer needed either.
|
|
49
|
+
"""
|
|
50
|
+
if 'fileId' not in event.info['upload']:
|
|
51
|
+
return
|
|
52
|
+
record = CheckoutFile().findByFile(event.info['file'])
|
|
53
|
+
if record is None:
|
|
54
|
+
return
|
|
55
|
+
utils.deleteOriginal(record)
|
|
56
|
+
CheckoutFile().remove(record)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _onAssetstoreRemove(event):
|
|
60
|
+
"""
|
|
61
|
+
Refuse to delete an assetstore that still holds the original data of a
|
|
62
|
+
checked-out file; core only sees that those files point elsewhere.
|
|
63
|
+
"""
|
|
64
|
+
if CheckoutFile().findOne(
|
|
65
|
+
{'originalAssetstoreId': event.info['_id']}, fields=['_id']) is not None:
|
|
66
|
+
raise ValidationException(
|
|
67
|
+
'You may not delete an assetstore that contains the original data of '
|
|
68
|
+
'files with local copies; release the checkouts first.')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _onFolderRemove(event):
|
|
72
|
+
checkout = Checkout().findByFolder(event.info)
|
|
73
|
+
if checkout is not None:
|
|
74
|
+
utils.removeCheckout(checkout)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _maybeCleanup(event):
|
|
78
|
+
"""
|
|
79
|
+
Schedule the daily cleanup of expired local copies if it is due. This is
|
|
80
|
+
bound to a frequent event so that it runs even on idle servers.
|
|
81
|
+
"""
|
|
82
|
+
# Imported here to avoid a circular import through girder_worker's plugin
|
|
83
|
+
# discovery when girder.tasks is first loaded
|
|
84
|
+
from girder.tasks import is_local_worker_available
|
|
85
|
+
|
|
86
|
+
if is_local_worker_available() and utils.cleanupIsDue():
|
|
87
|
+
from . import tasks
|
|
88
|
+
|
|
89
|
+
tasks.cleanupTask.delay()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LocalCheckoutPlugin(plugin.GirderPlugin):
|
|
93
|
+
DISPLAY_NAME = 'Local Checkout'
|
|
94
|
+
|
|
95
|
+
def load(self, info):
|
|
96
|
+
plugin.getPlugin('jobs').load(info)
|
|
97
|
+
|
|
98
|
+
ModelImporter.registerModel('localCheckout', Checkout, 'local_checkout')
|
|
99
|
+
ModelImporter.registerModel('localCheckoutFile', CheckoutFile, 'local_checkout')
|
|
100
|
+
|
|
101
|
+
info['apiRoot'].local_checkout = rest.LocalCheckout()
|
|
102
|
+
info['apiRoot'].folder.route('GET', (':id', 'local_checkout'), rest.getFolderCheckout)
|
|
103
|
+
info['apiRoot'].folder.route('PUT', (':id', 'local_checkout'), rest.setFolderCheckout)
|
|
104
|
+
info['apiRoot'].folder.route('DELETE', (':id', 'local_checkout'),
|
|
105
|
+
rest.deleteFolderCheckout)
|
|
106
|
+
|
|
107
|
+
events.bind('model.file.remove', 'local_checkout', _onFileRemove)
|
|
108
|
+
events.bind('model.file.finalizeUpload.before', 'local_checkout', _onFileReplaced)
|
|
109
|
+
events.bind('model.folder.remove', 'local_checkout', _onFolderRemove)
|
|
110
|
+
events.bind('model.assetstore.remove', 'local_checkout', _onAssetstoreRemove)
|
|
111
|
+
events.bind('model.file.download.request', 'local_checkout', _maybeCleanup)
|
|
112
|
+
_wrapUploadMethods()
|
|
113
|
+
|
|
114
|
+
plugin.registerPluginStaticContent(
|
|
115
|
+
plugin='local_checkout',
|
|
116
|
+
css=['/style.css'],
|
|
117
|
+
js=['/girder-plugin-local-checkout.umd.cjs'],
|
|
118
|
+
staticDir=Path(__file__).parent / 'web_client' / 'dist',
|
|
119
|
+
tree=info['serverRoot'],
|
|
120
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from bson.objectid import ObjectId
|
|
4
|
+
|
|
5
|
+
from girder.exceptions import ValidationException
|
|
6
|
+
from girder.models.model_base import Model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CheckoutStatus:
|
|
10
|
+
PENDING = 'pending'
|
|
11
|
+
RUNNING = 'running'
|
|
12
|
+
COMPLETE = 'complete'
|
|
13
|
+
# Not all files could be copied, typically because of insufficient space.
|
|
14
|
+
PARTIAL = 'partial'
|
|
15
|
+
ERROR = 'error'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Checkout(Model):
|
|
19
|
+
"""
|
|
20
|
+
A folder-level checkout: a request that all S3 files within a folder be
|
|
21
|
+
made local until a given expiration time.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def initialize(self):
|
|
25
|
+
self.name = 'localCheckout'
|
|
26
|
+
self.ensureIndices(['folderId', 'expires'])
|
|
27
|
+
|
|
28
|
+
def validate(self, doc):
|
|
29
|
+
for key in ('folderId', 'expires', 'days'):
|
|
30
|
+
if key not in doc:
|
|
31
|
+
raise ValidationException('Field missing.', key)
|
|
32
|
+
return doc
|
|
33
|
+
|
|
34
|
+
def createCheckout(self, folder, user, days, assetstore):
|
|
35
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
36
|
+
return self.save({
|
|
37
|
+
'folderId': folder['_id'],
|
|
38
|
+
'assetstoreId': assetstore['_id'],
|
|
39
|
+
'userId': user['_id'] if user else None,
|
|
40
|
+
'days': days,
|
|
41
|
+
'created': now,
|
|
42
|
+
'updated': now,
|
|
43
|
+
'expires': now + datetime.timedelta(days=days),
|
|
44
|
+
'status': CheckoutStatus.PENDING,
|
|
45
|
+
'fileCount': 0,
|
|
46
|
+
'localCount': 0,
|
|
47
|
+
'bytes': 0,
|
|
48
|
+
'message': '',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
def findByFolder(self, folder):
|
|
52
|
+
return self.findOne({'folderId': folder['_id']})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CheckoutFile(Model):
|
|
56
|
+
"""
|
|
57
|
+
A file that currently has a local copy. This records where the file's
|
|
58
|
+
data originally lived so that the file can be pointed back at it when the
|
|
59
|
+
local copy is removed.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def initialize(self):
|
|
63
|
+
self.name = 'localCheckoutFile'
|
|
64
|
+
self.ensureIndices([
|
|
65
|
+
([('fileId', 1)], {'unique': True}),
|
|
66
|
+
'expires',
|
|
67
|
+
'checkoutIds',
|
|
68
|
+
'original.relpath',
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
def validate(self, doc):
|
|
72
|
+
for key in ('fileId', 'original', 'localAssetstoreId', 'expires'):
|
|
73
|
+
if key not in doc:
|
|
74
|
+
raise ValidationException('Field missing.', key)
|
|
75
|
+
return doc
|
|
76
|
+
|
|
77
|
+
def findByFile(self, file):
|
|
78
|
+
return self.findOne({'fileId': file['_id']})
|
|
79
|
+
|
|
80
|
+
def addCheckout(self, record, checkout):
|
|
81
|
+
"""
|
|
82
|
+
Associate a checkout with a file record, extending the record's
|
|
83
|
+
expiration if the checkout lasts longer.
|
|
84
|
+
"""
|
|
85
|
+
update = {'$addToSet': {'checkoutIds': checkout['_id']}}
|
|
86
|
+
if checkout['expires'] > record['expires']:
|
|
87
|
+
update['$set'] = {'expires': checkout['expires']}
|
|
88
|
+
record['expires'] = checkout['expires']
|
|
89
|
+
if checkout['_id'] not in record['checkoutIds']:
|
|
90
|
+
record['checkoutIds'].append(checkout['_id'])
|
|
91
|
+
self.update({'_id': record['_id']}, update, multi=False)
|
|
92
|
+
return record
|
|
93
|
+
|
|
94
|
+
def computeExpires(self, record):
|
|
95
|
+
"""
|
|
96
|
+
Return the latest expiration of any checkout that still covers the
|
|
97
|
+
record, or None if no checkout covers it any more.
|
|
98
|
+
"""
|
|
99
|
+
ids = [ObjectId(i) for i in record.get('checkoutIds', [])]
|
|
100
|
+
if not ids:
|
|
101
|
+
return None
|
|
102
|
+
latest = None
|
|
103
|
+
for checkout in Checkout().find({'_id': {'$in': ids}}, fields=['expires']):
|
|
104
|
+
if latest is None or checkout['expires'] > latest:
|
|
105
|
+
latest = checkout['expires']
|
|
106
|
+
return latest
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from girder.api import access
|
|
2
|
+
from girder.api.describe import Description, autoDescribeRoute
|
|
3
|
+
from girder.api.rest import Resource, boundHandler
|
|
4
|
+
from girder.constants import AccessType, SortDir, TokenScope
|
|
5
|
+
from girder.exceptions import GirderException, RestException
|
|
6
|
+
from girder.models.assetstore import Assetstore
|
|
7
|
+
from girder.models.folder import Folder
|
|
8
|
+
from girder.models.setting import Setting
|
|
9
|
+
from girder.models.user import User
|
|
10
|
+
from girder.utility import assetstore_utilities
|
|
11
|
+
from girder.utility import path as path_util
|
|
12
|
+
|
|
13
|
+
from . import tasks, utils
|
|
14
|
+
from .models import Checkout, CheckoutFile
|
|
15
|
+
from .settings import PluginSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _checkPermission(user):
|
|
19
|
+
if Setting().get(PluginSettings.ADMIN_ONLY) and not user.get('admin'):
|
|
20
|
+
raise RestException('Only administrators may checkout folders.', code=403)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ensureWorker():
|
|
24
|
+
# Imported here to avoid a circular import through girder_worker's plugin
|
|
25
|
+
# discovery when girder.tasks is first loaded
|
|
26
|
+
from girder.tasks import ensure_local_worker_available
|
|
27
|
+
|
|
28
|
+
ensure_local_worker_available()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _requireAssetstore(assetstoreId):
|
|
32
|
+
try:
|
|
33
|
+
return utils.resolveCheckoutAssetstore(assetstoreId)
|
|
34
|
+
except GirderException as exc:
|
|
35
|
+
raise RestException(str(exc))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _assetstoreInfo(assetstore):
|
|
39
|
+
capacity = assetstore_utilities.getAssetstoreAdapter(assetstore).capacityInfo()
|
|
40
|
+
return {'_id': assetstore['_id'], 'name': assetstore['name'], 'free': capacity.get('free')}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@access.user(scope=TokenScope.DATA_READ)
|
|
44
|
+
@boundHandler
|
|
45
|
+
@autoDescribeRoute(
|
|
46
|
+
Description('Get the local checkout status of a folder.')
|
|
47
|
+
.modelParam('id', model=Folder, level=AccessType.READ)
|
|
48
|
+
.param('details', 'Whether to count the files in the folder that can be or '
|
|
49
|
+
'have been made local. This can be slow for large folders.',
|
|
50
|
+
dataType='boolean', default=True, required=False)
|
|
51
|
+
.errorResponse('ID was invalid.')
|
|
52
|
+
.errorResponse('Read access was denied for the folder.', 403)
|
|
53
|
+
)
|
|
54
|
+
def getFolderCheckout(self, folder, details):
|
|
55
|
+
assetstore = utils.getTargetAssetstore()
|
|
56
|
+
# When there is no configured or default filesystem assetstore, the user
|
|
57
|
+
# chooses one of these
|
|
58
|
+
choices = [] if assetstore else [
|
|
59
|
+
_assetstoreInfo(choice) for choice in utils.getFilesystemAssetstores()]
|
|
60
|
+
result = {
|
|
61
|
+
'checkout': Checkout().findByFolder(folder),
|
|
62
|
+
'enabled': assetstore is not None or bool(choices),
|
|
63
|
+
'assetstoreName': assetstore['name'] if assetstore else None,
|
|
64
|
+
'assetstores': choices,
|
|
65
|
+
'maxDays': Setting().get(PluginSettings.MAX_DAYS),
|
|
66
|
+
'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
|
|
67
|
+
}
|
|
68
|
+
if details:
|
|
69
|
+
result.update(utils.folderStatus(folder))
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@access.user(scope=TokenScope.DATA_WRITE)
|
|
74
|
+
@boundHandler
|
|
75
|
+
@autoDescribeRoute(
|
|
76
|
+
Description('Checkout a folder, making local copies of its S3 files for a number of days.')
|
|
77
|
+
.notes('If the folder is already checked out, its duration is changed. A '
|
|
78
|
+
'duration of 0 days releases the checkout. Copying is done in a '
|
|
79
|
+
'background task.')
|
|
80
|
+
.modelParam('id', model=Folder, level=AccessType.WRITE)
|
|
81
|
+
.param('days', 'The number of days to keep local copies.', dataType='integer')
|
|
82
|
+
.param('assetstoreId', 'The filesystem assetstore to copy files to. This is only '
|
|
83
|
+
'used when no assetstore is configured for local checkouts and the default '
|
|
84
|
+
'assetstore is not a filesystem assetstore; it may be omitted if there is only '
|
|
85
|
+
'one filesystem assetstore.', required=False)
|
|
86
|
+
.param('progress', 'Whether to record progress on the copy.', dataType='boolean',
|
|
87
|
+
default=True, required=False)
|
|
88
|
+
.errorResponse('ID was invalid.')
|
|
89
|
+
.errorResponse('Write access was denied for the folder.', 403)
|
|
90
|
+
)
|
|
91
|
+
def setFolderCheckout(self, folder, days, assetstoreId, progress):
|
|
92
|
+
user = self.getCurrentUser()
|
|
93
|
+
_checkPermission(user)
|
|
94
|
+
if days <= 0:
|
|
95
|
+
return _release(user, folder, progress)
|
|
96
|
+
maxDays = Setting().get(PluginSettings.MAX_DAYS)
|
|
97
|
+
if days > maxDays:
|
|
98
|
+
raise RestException('The checkout duration may not exceed %d days.' % maxDays)
|
|
99
|
+
checkout = Checkout().findByFolder(folder)
|
|
100
|
+
if not assetstoreId and checkout is not None:
|
|
101
|
+
assetstoreId = checkout.get('assetstoreId')
|
|
102
|
+
assetstore = _requireAssetstore(assetstoreId)
|
|
103
|
+
_ensureWorker()
|
|
104
|
+
if checkout is None:
|
|
105
|
+
checkout = Checkout().createCheckout(folder, user, days, assetstore)
|
|
106
|
+
else:
|
|
107
|
+
checkout = utils.updateCheckoutExpiry(checkout, days, assetstore)
|
|
108
|
+
tasks.checkoutFolderTask.delay(
|
|
109
|
+
checkoutId=str(checkout['_id']), userId=str(user['_id']), progress=progress)
|
|
110
|
+
return checkout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@access.user(scope=TokenScope.DATA_WRITE)
|
|
114
|
+
@boundHandler
|
|
115
|
+
@autoDescribeRoute(
|
|
116
|
+
Description('Release the local checkout of a folder.')
|
|
117
|
+
.notes('Local copies that no other checkout covers are removed in a background task.')
|
|
118
|
+
.modelParam('id', model=Folder, level=AccessType.WRITE)
|
|
119
|
+
.param('progress', 'Whether to record progress on the release.', dataType='boolean',
|
|
120
|
+
default=True, required=False)
|
|
121
|
+
.errorResponse('ID was invalid.')
|
|
122
|
+
.errorResponse('Write access was denied for the folder.', 403)
|
|
123
|
+
)
|
|
124
|
+
def deleteFolderCheckout(self, folder, progress):
|
|
125
|
+
user = self.getCurrentUser()
|
|
126
|
+
_checkPermission(user)
|
|
127
|
+
return _release(user, folder, progress)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _release(user, folder, progress):
|
|
131
|
+
checkout = Checkout().findByFolder(folder)
|
|
132
|
+
if checkout is None:
|
|
133
|
+
return {'released': False}
|
|
134
|
+
_ensureWorker()
|
|
135
|
+
Checkout().remove(checkout)
|
|
136
|
+
tasks.detachCheckoutTask.delay(
|
|
137
|
+
checkoutId=str(checkout['_id']), userId=str(user['_id']), progress=progress,
|
|
138
|
+
folderName=folder['name'])
|
|
139
|
+
return {'released': True}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LocalCheckout(Resource):
|
|
143
|
+
"""Administrative endpoints for local checkouts."""
|
|
144
|
+
|
|
145
|
+
def __init__(self):
|
|
146
|
+
super().__init__()
|
|
147
|
+
self.resourceName = 'local_checkout'
|
|
148
|
+
self.route('GET', (), self.listCheckouts)
|
|
149
|
+
self.route('GET', ('settings',), self.getSettings)
|
|
150
|
+
self.route('POST', ('cleanup',), self.cleanup)
|
|
151
|
+
|
|
152
|
+
@access.public
|
|
153
|
+
@autoDescribeRoute(
|
|
154
|
+
Description('Get the settings that affect whether a user may checkout folders.')
|
|
155
|
+
)
|
|
156
|
+
def getSettings(self):
|
|
157
|
+
return {
|
|
158
|
+
'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
|
|
159
|
+
'maxDays': Setting().get(PluginSettings.MAX_DAYS),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@access.admin(scope=TokenScope.DATA_READ)
|
|
163
|
+
@autoDescribeRoute(
|
|
164
|
+
Description('List all local checkouts and summarize the space they use.')
|
|
165
|
+
.pagingParams(defaultSort='expires', defaultSortDir=SortDir.ASCENDING)
|
|
166
|
+
)
|
|
167
|
+
def listCheckouts(self, limit, offset, sort):
|
|
168
|
+
user = self.getCurrentUser()
|
|
169
|
+
checkouts = list(Checkout().find({}, limit=limit, offset=offset, sort=sort))
|
|
170
|
+
for checkout in checkouts:
|
|
171
|
+
folder = Folder().load(checkout['folderId'], force=True)
|
|
172
|
+
checkout['folderPath'] = (
|
|
173
|
+
path_util.getResourcePath('folder', folder, user=user, force=True)
|
|
174
|
+
if folder else None)
|
|
175
|
+
owner = User().load(checkout['userId'], force=True) if checkout.get('userId') else None
|
|
176
|
+
checkout['userLogin'] = owner['login'] if owner else None
|
|
177
|
+
assetstore = (Assetstore().load(checkout['assetstoreId'])
|
|
178
|
+
if checkout.get('assetstoreId') else None)
|
|
179
|
+
checkout['assetstoreName'] = assetstore['name'] if assetstore else None
|
|
180
|
+
|
|
181
|
+
perAssetstore = {
|
|
182
|
+
entry['_id']: entry for entry in CheckoutFile().collection.aggregate([
|
|
183
|
+
{'$group': {'_id': '$localAssetstoreId', 'count': {'$sum': 1},
|
|
184
|
+
'bytes': {'$sum': '$size'}}}
|
|
185
|
+
])}
|
|
186
|
+
# Summarize the assetstores that hold local copies or can receive them
|
|
187
|
+
target = utils.getTargetAssetstore()
|
|
188
|
+
candidates = [target] if target else utils.getFilesystemAssetstores()
|
|
189
|
+
candidateIds = {assetstore['_id'] for assetstore in candidates}
|
|
190
|
+
candidates += [assetstore for assetstore in (
|
|
191
|
+
Assetstore().load(assetstoreId) for assetstoreId in perAssetstore
|
|
192
|
+
if assetstoreId not in candidateIds) if assetstore is not None]
|
|
193
|
+
assetstores = []
|
|
194
|
+
for assetstore in candidates:
|
|
195
|
+
totals = perAssetstore.get(assetstore['_id'], {'count': 0, 'bytes': 0})
|
|
196
|
+
assetstores.append({
|
|
197
|
+
'id': assetstore['_id'],
|
|
198
|
+
'name': assetstore['name'],
|
|
199
|
+
'capacity': assetstore_utilities.getAssetstoreAdapter(
|
|
200
|
+
assetstore).capacityInfo(),
|
|
201
|
+
'localFileCount': totals['count'],
|
|
202
|
+
'localBytes': totals['bytes'],
|
|
203
|
+
})
|
|
204
|
+
return {
|
|
205
|
+
'checkouts': checkouts,
|
|
206
|
+
'localFileCount': sum(entry['count'] for entry in perAssetstore.values()),
|
|
207
|
+
'localBytes': sum(entry['bytes'] for entry in perAssetstore.values()),
|
|
208
|
+
'assetstores': assetstores,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@access.admin(scope=TokenScope.DATA_WRITE)
|
|
212
|
+
@autoDescribeRoute(
|
|
213
|
+
Description('Remove expired checkouts and release their local copies now.')
|
|
214
|
+
.notes('This is otherwise done daily and before each checkout.')
|
|
215
|
+
)
|
|
216
|
+
def cleanup(self):
|
|
217
|
+
return {'released': utils.cleanupExpired()}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from bson.objectid import ObjectId
|
|
2
|
+
|
|
3
|
+
from girder.constants import AssetstoreType
|
|
4
|
+
from girder.exceptions import ValidationException
|
|
5
|
+
from girder.models.assetstore import Assetstore
|
|
6
|
+
from girder.utility import setting_utilities
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PluginSettings:
|
|
10
|
+
# The ID of the filesystem assetstore that local copies are placed in. If
|
|
11
|
+
# empty, the current default assetstore is used when it is a filesystem
|
|
12
|
+
# assetstore.
|
|
13
|
+
ASSETSTORE_ID = 'local_checkout.assetstore_id'
|
|
14
|
+
# The longest checkout duration, in days, that may be requested.
|
|
15
|
+
MAX_DAYS = 'local_checkout.max_days'
|
|
16
|
+
# If true, only site administrators may checkout folders.
|
|
17
|
+
ADMIN_ONLY = 'local_checkout.admin_only'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@setting_utilities.default(PluginSettings.ASSETSTORE_ID)
|
|
21
|
+
def _defaultAssetstoreId():
|
|
22
|
+
return ''
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@setting_utilities.default(PluginSettings.MAX_DAYS)
|
|
26
|
+
def _defaultMaxDays():
|
|
27
|
+
return 90
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@setting_utilities.default(PluginSettings.ADMIN_ONLY)
|
|
31
|
+
def _defaultAdminOnly():
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@setting_utilities.validator(PluginSettings.ASSETSTORE_ID)
|
|
36
|
+
def _validateAssetstoreId(doc):
|
|
37
|
+
value = doc['value']
|
|
38
|
+
if value in (None, ''):
|
|
39
|
+
doc['value'] = ''
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
assetstore = Assetstore().load(ObjectId(str(value)))
|
|
43
|
+
except Exception:
|
|
44
|
+
assetstore = None
|
|
45
|
+
if assetstore is None:
|
|
46
|
+
raise ValidationException('Assetstore ID must refer to an existing assetstore.', 'value')
|
|
47
|
+
if assetstore['type'] != AssetstoreType.FILESYSTEM:
|
|
48
|
+
raise ValidationException('The checkout assetstore must be a filesystem assetstore.',
|
|
49
|
+
'value')
|
|
50
|
+
doc['value'] = str(assetstore['_id'])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@setting_utilities.validator(PluginSettings.MAX_DAYS)
|
|
54
|
+
def _validateMaxDays(doc):
|
|
55
|
+
try:
|
|
56
|
+
value = int(doc['value'])
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
raise ValidationException('Maximum checkout days must be an integer.', 'value')
|
|
59
|
+
if value < 1:
|
|
60
|
+
raise ValidationException('Maximum checkout days must be at least 1.', 'value')
|
|
61
|
+
doc['value'] = value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@setting_utilities.validator(PluginSettings.ADMIN_ONLY)
|
|
65
|
+
def _validateAdminOnly(doc):
|
|
66
|
+
if not isinstance(doc['value'], bool):
|
|
67
|
+
raise ValidationException('Admin-only setting must be true or false.', 'value')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from girder_worker.app import app
|
|
2
|
+
|
|
3
|
+
from girder.models.folder import Folder
|
|
4
|
+
from girder.models.user import User
|
|
5
|
+
from girder.utility.progress import ProgressContext
|
|
6
|
+
|
|
7
|
+
from . import utils
|
|
8
|
+
from .models import Checkout
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.task(queue='local')
|
|
12
|
+
def checkoutFolderTask(checkoutId: str, userId: str, progress: bool):
|
|
13
|
+
user = User().load(userId, force=True)
|
|
14
|
+
checkout = Checkout().load(checkoutId)
|
|
15
|
+
if checkout is None:
|
|
16
|
+
return
|
|
17
|
+
folder = Folder().load(checkout['folderId'], force=True)
|
|
18
|
+
title = 'Making folder "%s" local' % (folder['name'] if folder else checkout['folderId'])
|
|
19
|
+
with ProgressContext(progress, user=user, title=title) as ctx:
|
|
20
|
+
utils.runCheckout(checkout, ctx)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.task(queue='local')
|
|
24
|
+
def detachCheckoutTask(checkoutId: str, userId: str, progress: bool, folderName: str):
|
|
25
|
+
user = User().load(userId, force=True)
|
|
26
|
+
with ProgressContext(progress, user=user,
|
|
27
|
+
title='Releasing local copies of folder "%s"' % folderName) as ctx:
|
|
28
|
+
utils.detachCheckout(checkoutId)
|
|
29
|
+
ctx.update(message='Done', force=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.task(queue='local')
|
|
33
|
+
def cleanupTask():
|
|
34
|
+
return utils.cleanupExpired()
|