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.
Files changed (27) hide show
  1. girder_local_checkout-5.0.23.dev7/MANIFEST.in +3 -0
  2. girder_local_checkout-5.0.23.dev7/PKG-INFO +41 -0
  3. girder_local_checkout-5.0.23.dev7/README.rst +13 -0
  4. girder_local_checkout-5.0.23.dev7/girder_local_checkout/__init__.py +120 -0
  5. girder_local_checkout-5.0.23.dev7/girder_local_checkout/girder_worker_plugin.py +9 -0
  6. girder_local_checkout-5.0.23.dev7/girder_local_checkout/models.py +106 -0
  7. girder_local_checkout-5.0.23.dev7/girder_local_checkout/rest.py +217 -0
  8. girder_local_checkout-5.0.23.dev7/girder_local_checkout/settings.py +67 -0
  9. girder_local_checkout-5.0.23.dev7/girder_local_checkout/tasks.py +34 -0
  10. girder_local_checkout-5.0.23.dev7/girder_local_checkout/utils.py +495 -0
  11. girder_local_checkout-5.0.23.dev7/girder_local_checkout/web_client/dist/girder-plugin-local-checkout.umd.cjs +16 -0
  12. girder_local_checkout-5.0.23.dev7/girder_local_checkout/web_client/dist/style.css +2 -0
  13. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/PKG-INFO +41 -0
  14. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/SOURCES.txt +25 -0
  15. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/dependency_links.txt +1 -0
  16. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/entry_points.txt +5 -0
  17. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/not-zip-safe +1 -0
  18. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/requires.txt +2 -0
  19. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/scm_file_list.json +30 -0
  20. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/scm_version.json +8 -0
  21. girder_local_checkout-5.0.23.dev7/girder_local_checkout.egg-info/top_level.txt +1 -0
  22. girder_local_checkout-5.0.23.dev7/plugin.cmake +1 -0
  23. girder_local_checkout-5.0.23.dev7/plugin_tests/__init__.py +0 -0
  24. girder_local_checkout-5.0.23.dev7/plugin_tests/test_local_checkout.py +499 -0
  25. girder_local_checkout-5.0.23.dev7/pyproject.toml +6 -0
  26. girder_local_checkout-5.0.23.dev7/setup.cfg +4 -0
  27. girder_local_checkout-5.0.23.dev7/setup.py +66 -0
@@ -0,0 +1,3 @@
1
+ prune girder_local_checkout/web_client
2
+ include girder_local_checkout/web_client/dist/girder-plugin-local-checkout.umd.cjs
3
+ include girder_local_checkout/web_client/dist/style.css
@@ -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,9 @@
1
+ from girder_worker import GirderWorkerPluginABC
2
+
3
+
4
+ class LocalCheckoutWorkerPlugin(GirderWorkerPluginABC):
5
+ def __init__(self, app, *args, **kwargs):
6
+ self.app = app
7
+
8
+ def task_imports(self):
9
+ return ['girder_local_checkout.tasks']
@@ -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()