girder-local-checkout 3.2.19.dev10__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 (32) hide show
  1. girder_local_checkout-3.2.19.dev10/PKG-INFO +41 -0
  2. girder_local_checkout-3.2.19.dev10/README.rst +13 -0
  3. girder_local_checkout-3.2.19.dev10/girder_local_checkout/__init__.py +105 -0
  4. girder_local_checkout-3.2.19.dev10/girder_local_checkout/models.py +106 -0
  5. girder_local_checkout-3.2.19.dev10/girder_local_checkout/rest.py +204 -0
  6. girder_local_checkout-3.2.19.dev10/girder_local_checkout/settings.py +67 -0
  7. girder_local_checkout-3.2.19.dev10/girder_local_checkout/tasks.py +132 -0
  8. girder_local_checkout-3.2.19.dev10/girder_local_checkout/utils.py +496 -0
  9. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/main.js +60 -0
  10. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/package.json +21 -0
  11. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/routes.js +12 -0
  12. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/stylesheets/localCheckout.styl +8 -0
  13. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/checkoutDialog.pug +75 -0
  14. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/config.pug +67 -0
  15. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/folderActions.pug +4 -0
  16. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/views/CheckoutDialog.js +99 -0
  17. girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/views/ConfigView.js +96 -0
  18. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/PKG-INFO +41 -0
  19. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/SOURCES.txt +30 -0
  20. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/dependency_links.txt +1 -0
  21. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/entry_points.txt +2 -0
  22. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/not-zip-safe +1 -0
  23. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/requires.txt +2 -0
  24. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/scm_file_list.json +25 -0
  25. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/scm_version.json +8 -0
  26. girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/top_level.txt +1 -0
  27. girder_local_checkout-3.2.19.dev10/plugin.cmake +1 -0
  28. girder_local_checkout-3.2.19.dev10/plugin_tests/__init__.py +0 -0
  29. girder_local_checkout-3.2.19.dev10/plugin_tests/local_checkout_test.py +523 -0
  30. girder_local_checkout-3.2.19.dev10/pyproject.toml +6 -0
  31. girder_local_checkout-3.2.19.dev10/setup.cfg +4 -0
  32. girder_local_checkout-3.2.19.dev10/setup.py +65 -0
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: girder-local-checkout
3
+ Version: 3.2.19.dev10
4
+ Summary: Temporarily copy S3 assetstore data to a filesystem assetstore.
5
+ Home-page: https://github.com/girder/girder/tree/3.x-maintenance/plugins/local_checkout
6
+ Author: Kitware, Inc.
7
+ Author-email: kitware@kitware.com
8
+ License: Apache 2.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/x-rst
16
+ Requires-Dist: girder>=3
17
+ Requires-Dist: girder-jobs>=3
18
+ Dynamic: author
19
+ Dynamic: author-email
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: home-page
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,105 @@
1
+ from girder import events, plugin
2
+ from girder.exceptions import ValidationException
3
+ from girder.utility.filesystem_assetstore_adapter import FilesystemAssetstoreAdapter
4
+ from girder.utility.model_importer import ModelImporter
5
+
6
+ from . import rest, tasks, utils
7
+ from .models import Checkout, CheckoutFile
8
+
9
+
10
+ def _wrapUploadMethods():
11
+ """
12
+ Make the filesystem assetstore adapter release local copies rather than
13
+ reject an upload when it is short of space.
14
+ """
15
+ if getattr(FilesystemAssetstoreAdapter, '_localCheckoutWrapped', False):
16
+ return
17
+ for name in ('initUpload', 'uploadChunk'):
18
+ base = getattr(FilesystemAssetstoreAdapter, name)
19
+
20
+ def wrapper(self, upload, *args, _base=base, **kwargs):
21
+ utils.makeRoomForUpload(self, upload)
22
+ return _base(self, upload, *args, **kwargs)
23
+
24
+ wrapper.__name__ = name
25
+ wrapper.__doc__ = base.__doc__
26
+ setattr(FilesystemAssetstoreAdapter, name, wrapper)
27
+ FilesystemAssetstoreAdapter._localCheckoutWrapped = True
28
+
29
+
30
+ def _onFileRemove(event):
31
+ """
32
+ When a checked-out file is deleted, core removes the local copy; we must
33
+ also remove the original data it came from.
34
+ """
35
+ record = CheckoutFile().findByFile(event.info)
36
+ if record is None:
37
+ return
38
+ utils.deleteOriginal(record)
39
+ CheckoutFile().remove(record)
40
+
41
+
42
+ def _onFileReplaced(event):
43
+ """
44
+ When the contents of a checked-out file are replaced (including being
45
+ moved to another assetstore), core has already discarded the local copy,
46
+ so the original data is no longer needed either.
47
+ """
48
+ if 'fileId' not in event.info['upload']:
49
+ return
50
+ record = CheckoutFile().findByFile(event.info['file'])
51
+ if record is None:
52
+ return
53
+ utils.deleteOriginal(record)
54
+ CheckoutFile().remove(record)
55
+
56
+
57
+ def _onAssetstoreRemove(event):
58
+ """
59
+ Refuse to delete an assetstore that still holds the original data of a
60
+ checked-out file; core only sees that those files point elsewhere.
61
+ """
62
+ if CheckoutFile().findOne(
63
+ {'originalAssetstoreId': event.info['_id']}, fields=['_id']) is not None:
64
+ raise ValidationException(
65
+ 'You may not delete an assetstore that contains the original data of '
66
+ 'files with local copies; release the checkouts first.')
67
+
68
+
69
+ def _onFolderRemove(event):
70
+ checkout = Checkout().findByFolder(event.info)
71
+ if checkout is not None:
72
+ utils.removeCheckout(checkout)
73
+
74
+
75
+ def _maybeCleanup(event):
76
+ """
77
+ Schedule the daily cleanup of expired local copies if it is due. This is
78
+ bound to a frequent event so that it runs even on idle servers.
79
+ """
80
+ if utils.cleanupIsDue():
81
+ tasks.scheduleCleanup()
82
+
83
+
84
+ class LocalCheckoutPlugin(plugin.GirderPlugin):
85
+ DISPLAY_NAME = 'Local Checkout'
86
+ CLIENT_SOURCE_PATH = 'web_client'
87
+
88
+ def load(self, info):
89
+ plugin.getPlugin('jobs').load(info)
90
+
91
+ ModelImporter.registerModel('localCheckout', Checkout, 'local_checkout')
92
+ ModelImporter.registerModel('localCheckoutFile', CheckoutFile, 'local_checkout')
93
+
94
+ info['apiRoot'].local_checkout = rest.LocalCheckout()
95
+ info['apiRoot'].folder.route('GET', (':id', 'local_checkout'), rest.getFolderCheckout)
96
+ info['apiRoot'].folder.route('PUT', (':id', 'local_checkout'), rest.setFolderCheckout)
97
+ info['apiRoot'].folder.route('DELETE', (':id', 'local_checkout'),
98
+ rest.deleteFolderCheckout)
99
+
100
+ events.bind('model.file.remove', 'local_checkout', _onFileRemove)
101
+ events.bind('model.file.finalizeUpload.before', 'local_checkout', _onFileReplaced)
102
+ events.bind('model.folder.remove', 'local_checkout', _onFolderRemove)
103
+ events.bind('model.assetstore.remove', 'local_checkout', _onAssetstoreRemove)
104
+ events.bind('model.file.download.request', 'local_checkout', _maybeCleanup)
105
+ _wrapUploadMethods()
@@ -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.utcnow()
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,204 @@
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 _requireAssetstore(assetstoreId):
24
+ try:
25
+ return utils.resolveCheckoutAssetstore(assetstoreId)
26
+ except GirderException as exc:
27
+ raise RestException(str(exc))
28
+
29
+
30
+ def _assetstoreInfo(assetstore):
31
+ capacity = assetstore_utilities.getAssetstoreAdapter(assetstore).capacityInfo()
32
+ return {'_id': assetstore['_id'], 'name': assetstore['name'], 'free': capacity.get('free')}
33
+
34
+
35
+ @access.user(scope=TokenScope.DATA_READ)
36
+ @boundHandler
37
+ @autoDescribeRoute(
38
+ Description('Get the local checkout status of a folder.')
39
+ .modelParam('id', model=Folder, level=AccessType.READ)
40
+ .param('details', 'Whether to count the files in the folder that can be or '
41
+ 'have been made local. This can be slow for large folders.',
42
+ dataType='boolean', default=True, required=False)
43
+ .errorResponse('ID was invalid.')
44
+ .errorResponse('Read access was denied for the folder.', 403)
45
+ )
46
+ def getFolderCheckout(self, folder, details):
47
+ assetstore = utils.getTargetAssetstore()
48
+ # When there is no configured or default filesystem assetstore, the user
49
+ # chooses one of these
50
+ choices = [] if assetstore else [
51
+ _assetstoreInfo(choice) for choice in utils.getFilesystemAssetstores()]
52
+ result = {
53
+ 'checkout': Checkout().findByFolder(folder),
54
+ 'enabled': assetstore is not None or bool(choices),
55
+ 'assetstoreName': assetstore['name'] if assetstore else None,
56
+ 'assetstores': choices,
57
+ 'maxDays': Setting().get(PluginSettings.MAX_DAYS),
58
+ 'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
59
+ }
60
+ if details:
61
+ result.update(utils.folderStatus(folder))
62
+ return result
63
+
64
+
65
+ @access.user(scope=TokenScope.DATA_WRITE)
66
+ @boundHandler
67
+ @autoDescribeRoute(
68
+ Description('Checkout a folder, making local copies of its S3 files for a number of days.')
69
+ .notes('If the folder is already checked out, its duration is changed. A '
70
+ 'duration of 0 days releases the checkout. Copying is done in a '
71
+ 'background job.')
72
+ .modelParam('id', model=Folder, level=AccessType.WRITE)
73
+ .param('days', 'The number of days to keep local copies.', dataType='integer')
74
+ .param('assetstoreId', 'The filesystem assetstore to copy files to. This is only '
75
+ 'used when no assetstore is configured for local checkouts and the default '
76
+ 'assetstore is not a filesystem assetstore; it may be omitted if there is only '
77
+ 'one filesystem assetstore.', required=False)
78
+ .param('progress', 'Whether to record progress on the copy.', dataType='boolean',
79
+ default=True, required=False)
80
+ .errorResponse('ID was invalid.')
81
+ .errorResponse('Write access was denied for the folder.', 403)
82
+ )
83
+ def setFolderCheckout(self, folder, days, assetstoreId, progress):
84
+ user = self.getCurrentUser()
85
+ _checkPermission(user)
86
+ if days <= 0:
87
+ return _release(user, folder, progress)
88
+ maxDays = Setting().get(PluginSettings.MAX_DAYS)
89
+ if days > maxDays:
90
+ raise RestException('The checkout duration may not exceed %d days.' % maxDays)
91
+ checkout = Checkout().findByFolder(folder)
92
+ if not assetstoreId and checkout is not None:
93
+ assetstoreId = checkout.get('assetstoreId')
94
+ assetstore = _requireAssetstore(assetstoreId)
95
+ if checkout is None:
96
+ checkout = Checkout().createCheckout(folder, user, days, assetstore)
97
+ else:
98
+ checkout = utils.updateCheckoutExpiry(checkout, days, assetstore)
99
+ tasks.scheduleCheckout(checkout, folder, user, progress)
100
+ return checkout
101
+
102
+
103
+ @access.user(scope=TokenScope.DATA_WRITE)
104
+ @boundHandler
105
+ @autoDescribeRoute(
106
+ Description('Release the local checkout of a folder.')
107
+ .notes('Local copies that no other checkout covers are removed in a background job.')
108
+ .modelParam('id', model=Folder, level=AccessType.WRITE)
109
+ .param('progress', 'Whether to record progress on the release.', dataType='boolean',
110
+ default=True, required=False)
111
+ .errorResponse('ID was invalid.')
112
+ .errorResponse('Write access was denied for the folder.', 403)
113
+ )
114
+ def deleteFolderCheckout(self, folder, progress):
115
+ user = self.getCurrentUser()
116
+ _checkPermission(user)
117
+ return _release(user, folder, progress)
118
+
119
+
120
+ def _release(user, folder, progress):
121
+ checkout = Checkout().findByFolder(folder)
122
+ if checkout is None:
123
+ return {'released': False}
124
+ Checkout().remove(checkout)
125
+ tasks.scheduleDetach(checkout, folder, user, progress)
126
+ return {'released': True}
127
+
128
+
129
+ class LocalCheckout(Resource):
130
+ """Administrative endpoints for local checkouts."""
131
+
132
+ def __init__(self):
133
+ super().__init__()
134
+ self.resourceName = 'local_checkout'
135
+ self.route('GET', (), self.listCheckouts)
136
+ self.route('GET', ('settings',), self.getSettings)
137
+ self.route('POST', ('cleanup',), self.cleanup)
138
+
139
+ @access.public
140
+ @autoDescribeRoute(
141
+ Description('Get the settings that affect whether a user may checkout folders.')
142
+ )
143
+ def getSettings(self):
144
+ return {
145
+ 'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
146
+ 'maxDays': Setting().get(PluginSettings.MAX_DAYS),
147
+ }
148
+
149
+ @access.admin(scope=TokenScope.DATA_READ)
150
+ @autoDescribeRoute(
151
+ Description('List all local checkouts and summarize the space they use.')
152
+ .pagingParams(defaultSort='expires', defaultSortDir=SortDir.ASCENDING)
153
+ )
154
+ def listCheckouts(self, limit, offset, sort):
155
+ user = self.getCurrentUser()
156
+ checkouts = list(Checkout().find({}, limit=limit, offset=offset, sort=sort))
157
+ for checkout in checkouts:
158
+ folder = Folder().load(checkout['folderId'], force=True)
159
+ checkout['folderPath'] = (
160
+ path_util.getResourcePath('folder', folder, user=user, force=True)
161
+ if folder else None)
162
+ owner = User().load(checkout['userId'], force=True) if checkout.get('userId') else None
163
+ checkout['userLogin'] = owner['login'] if owner else None
164
+ assetstore = (Assetstore().load(checkout['assetstoreId'])
165
+ if checkout.get('assetstoreId') else None)
166
+ checkout['assetstoreName'] = assetstore['name'] if assetstore else None
167
+
168
+ perAssetstore = {
169
+ entry['_id']: entry for entry in CheckoutFile().collection.aggregate([
170
+ {'$group': {'_id': '$localAssetstoreId', 'count': {'$sum': 1},
171
+ 'bytes': {'$sum': '$size'}}}
172
+ ])}
173
+ # Summarize the assetstores that hold local copies or can receive them
174
+ target = utils.getTargetAssetstore()
175
+ candidates = [target] if target else utils.getFilesystemAssetstores()
176
+ candidateIds = {assetstore['_id'] for assetstore in candidates}
177
+ candidates += [assetstore for assetstore in (
178
+ Assetstore().load(assetstoreId) for assetstoreId in perAssetstore
179
+ if assetstoreId not in candidateIds) if assetstore is not None]
180
+ assetstores = []
181
+ for assetstore in candidates:
182
+ totals = perAssetstore.get(assetstore['_id'], {'count': 0, 'bytes': 0})
183
+ assetstores.append({
184
+ 'id': assetstore['_id'],
185
+ 'name': assetstore['name'],
186
+ 'capacity': assetstore_utilities.getAssetstoreAdapter(
187
+ assetstore).capacityInfo(),
188
+ 'localFileCount': totals['count'],
189
+ 'localBytes': totals['bytes'],
190
+ })
191
+ return {
192
+ 'checkouts': checkouts,
193
+ 'localFileCount': sum(entry['count'] for entry in perAssetstore.values()),
194
+ 'localBytes': sum(entry['bytes'] for entry in perAssetstore.values()),
195
+ 'assetstores': assetstores,
196
+ }
197
+
198
+ @access.admin(scope=TokenScope.DATA_WRITE)
199
+ @autoDescribeRoute(
200
+ Description('Remove expired checkouts and release their local copies now.')
201
+ .notes('This is otherwise done daily and before each checkout.')
202
+ )
203
+ def cleanup(self):
204
+ 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,132 @@
1
+ import logging
2
+ import threading
3
+ import traceback
4
+
5
+ from girder import events
6
+ from girder.models.folder import Folder
7
+ from girder.models.user import User
8
+ from girder.utility.progress import ProgressContext
9
+ from girder_jobs.constants import JobStatus
10
+ from girder_jobs.models.job import Job
11
+
12
+ from . import utils
13
+ from .models import Checkout
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _runInThread(target, name):
19
+ """
20
+ Run a callable in a new daemon thread. Tests replace this to run the
21
+ work synchronously.
22
+ """
23
+ threading.Thread(target=target, name=name, daemon=True).start()
24
+
25
+
26
+ def _scheduleJob(title, jobType, function, user, kwargs):
27
+ """
28
+ Create a local job that runs a function of this module and schedule it in
29
+ a thread of its own. The Girder events daemon is not used because it
30
+ handles one event at a time, and copying a large folder would hold up
31
+ everything else that runs on it.
32
+ """
33
+ job = Job().createLocalJob(
34
+ title=title, type=jobType, user=user, public=False,
35
+ module=__name__, function=function, kwargs=kwargs)
36
+ _runInThread(lambda: Job().scheduleJob(job), 'local_checkout-%s' % job['_id'])
37
+ return job
38
+
39
+
40
+ def _runJob(job, work):
41
+ """
42
+ Run the work of a local job, recording its outcome on the job document.
43
+ Failures are logged rather than raised, since the job runs in a thread
44
+ where nothing could handle them.
45
+ """
46
+ Job().updateJob(job, status=JobStatus.RUNNING)
47
+ try:
48
+ result = work(**job['kwargs'])
49
+ except Exception:
50
+ logger.exception('Local checkout job %s failed', job['_id'])
51
+ Job().updateJob(job, status=JobStatus.ERROR, log=traceback.format_exc())
52
+ return None
53
+ Job().updateJob(job, status=JobStatus.SUCCESS,
54
+ log='' if result is None else str(result))
55
+ return result
56
+
57
+
58
+ def scheduleCheckout(checkout, folder, user, progress):
59
+ """
60
+ Schedule the copying of a checkout's files.
61
+ """
62
+ return _scheduleJob(
63
+ 'Making folder "%s" local' % folder['name'], 'local_checkout.checkout',
64
+ 'checkoutFolderTask', user, {
65
+ 'checkoutId': str(checkout['_id']),
66
+ 'userId': str(user['_id']),
67
+ 'progress': progress,
68
+ })
69
+
70
+
71
+ def scheduleDetach(checkout, folder, user, progress):
72
+ """
73
+ Schedule the release of the local copies a checkout no longer covers.
74
+ """
75
+ return _scheduleJob(
76
+ 'Releasing local copies of folder "%s"' % folder['name'],
77
+ 'local_checkout.release', 'detachCheckoutTask', user, {
78
+ 'checkoutId': str(checkout['_id']),
79
+ 'userId': str(user['_id']),
80
+ 'progress': progress,
81
+ 'folderName': folder['name'],
82
+ })
83
+
84
+
85
+ def scheduleCleanup():
86
+ """
87
+ Run the cleanup of expired local copies on the events daemon.
88
+ """
89
+ events.daemon.trigger(callback=cleanupTask)
90
+
91
+
92
+ def _checkoutFolder(checkoutId, userId, progress):
93
+ user = User().load(userId, force=True)
94
+ checkout = Checkout().load(checkoutId)
95
+ if checkout is None:
96
+ return 'The checkout was released before it started.'
97
+ folder = Folder().load(checkout['folderId'], force=True)
98
+ title = 'Making folder "%s" local' % (folder['name'] if folder else checkout['folderId'])
99
+ with ProgressContext(progress, user=user, title=title) as ctx:
100
+ utils.runCheckout(checkout, ctx)
101
+ checkout = Checkout().load(checkoutId)
102
+ if checkout is None:
103
+ return 'The checkout was released while it was running.'
104
+ summary = '%d of %d files are local; status: %s.' % (
105
+ checkout['localCount'], checkout['fileCount'], checkout['status'])
106
+ if checkout.get('message'):
107
+ summary += ' ' + checkout['message']
108
+ return summary
109
+
110
+
111
+ def _detachCheckout(checkoutId, userId, progress, folderName):
112
+ user = User().load(userId, force=True)
113
+ with ProgressContext(progress, user=user,
114
+ title='Releasing local copies of folder "%s"' % folderName) as ctx:
115
+ released = utils.detachCheckout(checkoutId)
116
+ ctx.update(message='Done', force=True)
117
+ return 'Released %d local %s.' % (released, 'copy' if released == 1 else 'copies')
118
+
119
+
120
+ def checkoutFolderTask(job):
121
+ return _runJob(job, _checkoutFolder)
122
+
123
+
124
+ def detachCheckoutTask(job):
125
+ return _runJob(job, _detachCheckout)
126
+
127
+
128
+ def cleanupTask(event=None):
129
+ try:
130
+ return utils.cleanupExpired()
131
+ except Exception:
132
+ logger.exception('Failed to clean up expired local checkouts')