girder-sample-tracker 2.0.0__py3-none-any.whl
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_sample_tracker/__init__.py +20 -0
- girder_sample_tracker/models/__init__.py +0 -0
- girder_sample_tracker/models/sample.py +96 -0
- girder_sample_tracker/rest/__init__.py +0 -0
- girder_sample_tracker/rest/sample.py +348 -0
- girder_sample_tracker/web_client/dist/girder-plugin-sample-tracker.umd.cjs +72 -0
- girder_sample_tracker/web_client/dist/style.css +1 -0
- girder_sample_tracker-2.0.0.dist-info/LICENSE +27 -0
- girder_sample_tracker-2.0.0.dist-info/METADATA +28 -0
- girder_sample_tracker-2.0.0.dist-info/RECORD +13 -0
- girder_sample_tracker-2.0.0.dist-info/WHEEL +5 -0
- girder_sample_tracker-2.0.0.dist-info/entry_points.txt +2 -0
- girder_sample_tracker-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from girder.plugin import GirderPlugin, registerPluginStaticContent
|
|
3
|
+
from girder.utility.model_importer import ModelImporter
|
|
4
|
+
from .rest.sample import Sample
|
|
5
|
+
from .models.sample import Sample as SampleModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SampleTrackerPlugin(GirderPlugin):
|
|
9
|
+
DISPLAY_NAME = "Sample Tracker"
|
|
10
|
+
|
|
11
|
+
def load(self, info):
|
|
12
|
+
ModelImporter.registerModel("sample", SampleModel, plugin="sample_tracker")
|
|
13
|
+
info["apiRoot"].sample = Sample()
|
|
14
|
+
registerPluginStaticContent(
|
|
15
|
+
plugin="sample_tracker",
|
|
16
|
+
css=["/style.css"],
|
|
17
|
+
js=["/girder-plugin-sample-tracker.umd.cjs"],
|
|
18
|
+
staticDir=os.path.join(os.path.dirname(__file__), "web_client", "dist"),
|
|
19
|
+
tree=info["serverRoot"],
|
|
20
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
import cairosvg
|
|
5
|
+
import qrcode
|
|
6
|
+
from girder.constants import AccessType
|
|
7
|
+
from girder.models.model_base import AccessControlledModel
|
|
8
|
+
from qrcode.compat.etree import ET
|
|
9
|
+
from qrcode.image.svg import SvgPathFillImage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Sample(AccessControlledModel):
|
|
13
|
+
def initialize(self):
|
|
14
|
+
self.name = "sample"
|
|
15
|
+
self.ensureIndices(["name"])
|
|
16
|
+
|
|
17
|
+
self.exposeFields(
|
|
18
|
+
level=AccessType.READ,
|
|
19
|
+
fields=(
|
|
20
|
+
"_id",
|
|
21
|
+
"created",
|
|
22
|
+
"creator",
|
|
23
|
+
"description",
|
|
24
|
+
"eventTypes",
|
|
25
|
+
"updated",
|
|
26
|
+
"name",
|
|
27
|
+
"events",
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def validate(self, doc):
|
|
32
|
+
return doc
|
|
33
|
+
|
|
34
|
+
def create(
|
|
35
|
+
self, name, creator, description=None, eventTypes=None, access=None, save=True
|
|
36
|
+
):
|
|
37
|
+
now = datetime.datetime.utcnow()
|
|
38
|
+
|
|
39
|
+
sample = {
|
|
40
|
+
"name": name,
|
|
41
|
+
"creator": creator["_id"],
|
|
42
|
+
"created": now,
|
|
43
|
+
"description": description,
|
|
44
|
+
"eventTypes": eventTypes or [],
|
|
45
|
+
"updated": now,
|
|
46
|
+
"events": [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if access is not None:
|
|
50
|
+
self.setAccessList(sample, access, save=False, user=creator)
|
|
51
|
+
else:
|
|
52
|
+
self.setUserAccess(sample, user=creator, level=AccessType.ADMIN, save=False)
|
|
53
|
+
if save:
|
|
54
|
+
sample = self.save(sample)
|
|
55
|
+
|
|
56
|
+
return sample
|
|
57
|
+
|
|
58
|
+
def add_event(self, sample, event, save=True):
|
|
59
|
+
sample["events"].insert(0, event)
|
|
60
|
+
sample["updated"] = event["created"]
|
|
61
|
+
|
|
62
|
+
if save:
|
|
63
|
+
sample = self.save(sample)
|
|
64
|
+
|
|
65
|
+
return sample
|
|
66
|
+
|
|
67
|
+
def qr_code(self, sample, url):
|
|
68
|
+
buf = io.BytesIO()
|
|
69
|
+
qr = qrcode.QRCode(
|
|
70
|
+
version=6,
|
|
71
|
+
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
|
72
|
+
border=10,
|
|
73
|
+
image_factory=SvgPathFillImage,
|
|
74
|
+
)
|
|
75
|
+
qr.add_data(f"{url}/#sample/{sample['_id']}/add")
|
|
76
|
+
qr.make(fit=True)
|
|
77
|
+
|
|
78
|
+
img = qr.make_image(fill_color="black", back_color="white")
|
|
79
|
+
text = ET.SubElement(
|
|
80
|
+
img._img,
|
|
81
|
+
"text",
|
|
82
|
+
{
|
|
83
|
+
"x": "50%",
|
|
84
|
+
"y": "93%",
|
|
85
|
+
"dominant-baseline": "middle",
|
|
86
|
+
"text-anchor": "middle",
|
|
87
|
+
"font-size": "5",
|
|
88
|
+
"fill": "black",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
text.text = sample["name"]
|
|
92
|
+
cairosvg.svg2png(
|
|
93
|
+
bytestring=img.to_string(encoding="unicode"), write_to=buf, dpi=300
|
|
94
|
+
)
|
|
95
|
+
buf.seek(0)
|
|
96
|
+
return buf
|
|
File without changes
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import math
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
import cherrypy
|
|
6
|
+
from girder.api import access
|
|
7
|
+
from girder.api.describe import Description, autoDescribeRoute
|
|
8
|
+
from girder.api.rest import (
|
|
9
|
+
Resource,
|
|
10
|
+
filtermodel,
|
|
11
|
+
setContentDisposition,
|
|
12
|
+
setResponseHeader,
|
|
13
|
+
)
|
|
14
|
+
from girder.constants import AccessType, SortDir, TokenScope
|
|
15
|
+
from girder.exceptions import RestException, ValidationException
|
|
16
|
+
from girder.utility import ziputil
|
|
17
|
+
from girder.utility.progress import ProgressContext
|
|
18
|
+
|
|
19
|
+
from ..models.sample import Sample as SampleModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bytesio_iterator(bio, chunk_size=4096):
|
|
23
|
+
"""
|
|
24
|
+
Iterates over the content of a BytesIO object in chunks.
|
|
25
|
+
"""
|
|
26
|
+
while True:
|
|
27
|
+
chunk = bio.read(chunk_size)
|
|
28
|
+
if not chunk:
|
|
29
|
+
break
|
|
30
|
+
yield chunk
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Sample(Resource):
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super(Sample, self).__init__()
|
|
36
|
+
self.resourceName = "sample"
|
|
37
|
+
self.route("GET", (), self.list_samples)
|
|
38
|
+
self.route("DELETE", (), self.delete_samples)
|
|
39
|
+
self.route("PUT", ("access",), self.bulk_update_access)
|
|
40
|
+
self.route("GET", (":id", "download"), self.download_sample)
|
|
41
|
+
self.route("POST", ("download",), self.download_samples)
|
|
42
|
+
self.route("GET", (":id",), self.get_sample)
|
|
43
|
+
self.route("PUT", (":id",), self.update_sample)
|
|
44
|
+
self.route("POST", (), self.create_sample)
|
|
45
|
+
self.route("DELETE", (":id",), self.delete_sample)
|
|
46
|
+
self.route("GET", (":id", "access"), self.get_access)
|
|
47
|
+
self.route("PUT", (":id", "access"), self.update_access)
|
|
48
|
+
self.route("POST", (":id", "event"), self.create_event)
|
|
49
|
+
|
|
50
|
+
@access.public
|
|
51
|
+
@autoDescribeRoute(
|
|
52
|
+
Description("List samples").pagingParams(
|
|
53
|
+
defaultSort="name", defaultSortDir=SortDir.DESCENDING
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
57
|
+
def list_samples(self, limit, offset, sort):
|
|
58
|
+
return SampleModel().findWithPermissions(
|
|
59
|
+
query={},
|
|
60
|
+
offset=offset,
|
|
61
|
+
limit=limit,
|
|
62
|
+
sort=sort,
|
|
63
|
+
user=self.getCurrentUser(),
|
|
64
|
+
level=AccessType.READ,
|
|
65
|
+
fields={"events": 0},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@access.public
|
|
69
|
+
@autoDescribeRoute(
|
|
70
|
+
Description("Get a sample by ID").modelParam(
|
|
71
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.READ
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
75
|
+
def get_sample(self, sample):
|
|
76
|
+
return sample
|
|
77
|
+
|
|
78
|
+
@access.user(scope=TokenScope.DATA_OWN)
|
|
79
|
+
@autoDescribeRoute(
|
|
80
|
+
Description("Update a sample")
|
|
81
|
+
.modelParam(
|
|
82
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.ADMIN
|
|
83
|
+
)
|
|
84
|
+
.param("name", "The name of the sample", required=False)
|
|
85
|
+
.param("description", "The description of the sample", required=False)
|
|
86
|
+
.jsonParam(
|
|
87
|
+
"eventTypes",
|
|
88
|
+
"The event types for the sample",
|
|
89
|
+
required=False,
|
|
90
|
+
requireArray=True,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
94
|
+
def update_sample(self, sample, name, description, eventTypes):
|
|
95
|
+
if name:
|
|
96
|
+
sample["name"] = name
|
|
97
|
+
if description:
|
|
98
|
+
sample["description"] = description
|
|
99
|
+
if eventTypes != sample.get("eventTypes", []):
|
|
100
|
+
sample["eventTypes"] = eventTypes
|
|
101
|
+
sample["updated"] = datetime.datetime.utcnow()
|
|
102
|
+
return SampleModel().save(sample)
|
|
103
|
+
|
|
104
|
+
@access.user
|
|
105
|
+
@autoDescribeRoute(
|
|
106
|
+
Description("Create a sample")
|
|
107
|
+
.param("name", "The name of the sample", required=True)
|
|
108
|
+
.param("description", "The description of the sample", required=False)
|
|
109
|
+
.jsonParam(
|
|
110
|
+
"eventTypes",
|
|
111
|
+
"The event types for the sample",
|
|
112
|
+
required=False,
|
|
113
|
+
requireArray=True,
|
|
114
|
+
)
|
|
115
|
+
.param(
|
|
116
|
+
"batchSize",
|
|
117
|
+
"The size of the batch. Default 1. Cannot be less than 1 and greater than 64.",
|
|
118
|
+
required=False,
|
|
119
|
+
dataType="integer",
|
|
120
|
+
)
|
|
121
|
+
.jsonParam(
|
|
122
|
+
"access", "The access control list as a JSON object", requireObject=False
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
126
|
+
def create_sample(self, name, description, eventTypes, batchSize, access):
|
|
127
|
+
if batchSize is None:
|
|
128
|
+
batchSize = 1
|
|
129
|
+
if batchSize < 1 or batchSize > 64:
|
|
130
|
+
raise ValidationException(
|
|
131
|
+
"Batch size must be at least 1, but no more than 64."
|
|
132
|
+
)
|
|
133
|
+
if not eventTypes:
|
|
134
|
+
eventTypes = []
|
|
135
|
+
user = self.getCurrentUser()
|
|
136
|
+
samples = []
|
|
137
|
+
if batchSize > 1:
|
|
138
|
+
format_str = "{name}{i:0" + str(math.ceil(math.log10(batchSize))) + "d}"
|
|
139
|
+
for i in range(batchSize):
|
|
140
|
+
sample = SampleModel().create(
|
|
141
|
+
format_str.format(name=name, i=i + 1),
|
|
142
|
+
user,
|
|
143
|
+
description=description,
|
|
144
|
+
eventTypes=eventTypes,
|
|
145
|
+
access=access,
|
|
146
|
+
)
|
|
147
|
+
samples.append(sample)
|
|
148
|
+
else:
|
|
149
|
+
samples.append(
|
|
150
|
+
SampleModel().create(
|
|
151
|
+
name,
|
|
152
|
+
user,
|
|
153
|
+
description=description,
|
|
154
|
+
eventTypes=eventTypes,
|
|
155
|
+
access=access,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return samples[0]
|
|
159
|
+
|
|
160
|
+
@access.user
|
|
161
|
+
@autoDescribeRoute(
|
|
162
|
+
Description("Delete a sample").modelParam(
|
|
163
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.WRITE
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
167
|
+
def delete_sample(self, sample):
|
|
168
|
+
SampleModel().remove(sample)
|
|
169
|
+
|
|
170
|
+
@access.user
|
|
171
|
+
@autoDescribeRoute(
|
|
172
|
+
Description("Delete multiple samples")
|
|
173
|
+
.jsonParam(
|
|
174
|
+
"ids",
|
|
175
|
+
"The IDs of the samples to delete",
|
|
176
|
+
requireArray=True,
|
|
177
|
+
)
|
|
178
|
+
.param(
|
|
179
|
+
"progress",
|
|
180
|
+
"Whether to record progress on this task.",
|
|
181
|
+
default=False,
|
|
182
|
+
required=False,
|
|
183
|
+
dataType="boolean",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
def delete_samples(self, ids, progress):
|
|
187
|
+
user = self.getCurrentUser()
|
|
188
|
+
total = len(ids)
|
|
189
|
+
with ProgressContext(
|
|
190
|
+
progress,
|
|
191
|
+
user=user,
|
|
192
|
+
title="Deleting resources",
|
|
193
|
+
message="Calculating size...",
|
|
194
|
+
) as ctx:
|
|
195
|
+
ctx.update(total=total)
|
|
196
|
+
current = 0
|
|
197
|
+
for sample_id in ids:
|
|
198
|
+
doc = SampleModel().load(
|
|
199
|
+
sample_id, user=user, level=AccessType.ADMIN, exc=True
|
|
200
|
+
)
|
|
201
|
+
SampleModel().remove(doc, progress=ctx)
|
|
202
|
+
if progress:
|
|
203
|
+
current += 1
|
|
204
|
+
if ctx.progress["data"]["current"] != current:
|
|
205
|
+
ctx.update(current=current, message="Deleted sample")
|
|
206
|
+
|
|
207
|
+
@access.user
|
|
208
|
+
@autoDescribeRoute(
|
|
209
|
+
Description("Get the access control list for a sample").modelParam(
|
|
210
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.ADMIN
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
def get_access(self, sample):
|
|
214
|
+
return SampleModel().getFullAccessList(sample)
|
|
215
|
+
|
|
216
|
+
@access.user(scope=TokenScope.DATA_OWN)
|
|
217
|
+
@autoDescribeRoute(
|
|
218
|
+
Description("Update the access control list for a sample")
|
|
219
|
+
.modelParam(
|
|
220
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.ADMIN
|
|
221
|
+
)
|
|
222
|
+
.jsonParam(
|
|
223
|
+
"access", "The access control list as a JSON object", requireObject=True
|
|
224
|
+
)
|
|
225
|
+
.jsonParam(
|
|
226
|
+
"publicFlags",
|
|
227
|
+
"Public access control flags",
|
|
228
|
+
requireArray=True,
|
|
229
|
+
required=False,
|
|
230
|
+
)
|
|
231
|
+
.param(
|
|
232
|
+
"public",
|
|
233
|
+
"Whether the resource should be publicly visible",
|
|
234
|
+
dataType="boolean",
|
|
235
|
+
required=False,
|
|
236
|
+
)
|
|
237
|
+
.errorResponse("ID was invalid.")
|
|
238
|
+
.errorResponse("Admin access was denied for the sample.", 403)
|
|
239
|
+
)
|
|
240
|
+
def update_access(self, sample, access, publicFlags, public):
|
|
241
|
+
return SampleModel().setAccessList(
|
|
242
|
+
sample, access, save=True, user=self.getCurrentUser()
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@access.user(scope=TokenScope.DATA_OWN)
|
|
246
|
+
@autoDescribeRoute(
|
|
247
|
+
Description("Update the access control list for multiple samples")
|
|
248
|
+
.jsonParam(
|
|
249
|
+
"ids",
|
|
250
|
+
"The IDs of the samples to update",
|
|
251
|
+
requireArray=True,
|
|
252
|
+
)
|
|
253
|
+
.jsonParam(
|
|
254
|
+
"access", "The access control list as a JSON object", requireObject=True
|
|
255
|
+
)
|
|
256
|
+
.jsonParam(
|
|
257
|
+
"publicFlags",
|
|
258
|
+
"Public access control flags",
|
|
259
|
+
requireArray=True,
|
|
260
|
+
required=False,
|
|
261
|
+
)
|
|
262
|
+
.param(
|
|
263
|
+
"public",
|
|
264
|
+
"Whether the resource should be publicly visible",
|
|
265
|
+
dataType="boolean",
|
|
266
|
+
required=False,
|
|
267
|
+
)
|
|
268
|
+
.errorResponse("ID was invalid.")
|
|
269
|
+
.errorResponse("Admin access was denied for the sample.", 403)
|
|
270
|
+
)
|
|
271
|
+
def bulk_update_access(self, ids, access, publicFlags, public):
|
|
272
|
+
user = self.getCurrentUser()
|
|
273
|
+
for sample_id in ids:
|
|
274
|
+
doc = SampleModel().load(
|
|
275
|
+
sample_id, user=user, level=AccessType.ADMIN, exc=True
|
|
276
|
+
)
|
|
277
|
+
sample = SampleModel().setAccessList(doc, access, save=True, user=user)
|
|
278
|
+
return sample
|
|
279
|
+
|
|
280
|
+
@access.user
|
|
281
|
+
@autoDescribeRoute(
|
|
282
|
+
Description("Create an event for a sample")
|
|
283
|
+
.modelParam(
|
|
284
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.WRITE
|
|
285
|
+
)
|
|
286
|
+
.param("eventType", "The type of the event", required=True)
|
|
287
|
+
.param("location", "The location of the event", required=False)
|
|
288
|
+
.param("comment", "Extra comment about the event", required=False)
|
|
289
|
+
)
|
|
290
|
+
@filtermodel(model="sample", plugin="sample_tracker")
|
|
291
|
+
def create_event(self, sample, eventType, location, comment):
|
|
292
|
+
user = self.getCurrentUser()
|
|
293
|
+
event = {
|
|
294
|
+
"comment": comment,
|
|
295
|
+
"created": datetime.datetime.utcnow(),
|
|
296
|
+
"creator": user["_id"],
|
|
297
|
+
"creatorName": f"{user['firstName']} {user['lastName']}",
|
|
298
|
+
"eventType": eventType,
|
|
299
|
+
"location": location,
|
|
300
|
+
}
|
|
301
|
+
return SampleModel().add_event(sample, event)
|
|
302
|
+
|
|
303
|
+
@access.public(scope=TokenScope.DATA_READ, cookie=True)
|
|
304
|
+
@autoDescribeRoute(
|
|
305
|
+
Description("Download a sample").modelParam(
|
|
306
|
+
"id", "The ID of the sample", model=SampleModel, level=AccessType.READ
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
def download_sample(self, sample):
|
|
310
|
+
url = urlparse(cherrypy.request.headers["Referer"])
|
|
311
|
+
girder_base = f"{url.scheme}://{url.netloc}"
|
|
312
|
+
qr_img = SampleModel().qr_code(sample, girder_base)
|
|
313
|
+
setResponseHeader("Content-Type", "image/png")
|
|
314
|
+
setContentDisposition(f"{sample['name']}.png")
|
|
315
|
+
return bytesio_iterator(qr_img)
|
|
316
|
+
|
|
317
|
+
@access.public(scope=TokenScope.DATA_READ, cookie=True)
|
|
318
|
+
@autoDescribeRoute(
|
|
319
|
+
Description("Download QR codes for a list of samples").jsonParam(
|
|
320
|
+
"ids",
|
|
321
|
+
"The IDs of the samples to download",
|
|
322
|
+
requireArray=True,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
def download_samples(self, ids):
|
|
326
|
+
user = self.getCurrentUser()
|
|
327
|
+
url = urlparse(cherrypy.request.headers["Referer"])
|
|
328
|
+
girder_base = f"{url.scheme}://{url.netloc}"
|
|
329
|
+
for sample_id in ids:
|
|
330
|
+
if not SampleModel().load(sample_id, user=user, level=AccessType.READ):
|
|
331
|
+
raise RestException(f"Sample {sample_id} not found or access denied.")
|
|
332
|
+
setResponseHeader("Content-Type", "application/zip")
|
|
333
|
+
setContentDisposition("samples.zip")
|
|
334
|
+
|
|
335
|
+
def stream():
|
|
336
|
+
_zip = ziputil.ZipGenerator()
|
|
337
|
+
for sample_id in ids:
|
|
338
|
+
doc = SampleModel().load(sample_id, user=user, level=AccessType.READ)
|
|
339
|
+
qr_img = SampleModel().qr_code(doc, girder_base)
|
|
340
|
+
|
|
341
|
+
def qr_stream():
|
|
342
|
+
yield qr_img.getvalue()
|
|
343
|
+
|
|
344
|
+
for data in _zip.addFile(qr_stream, f"{doc['name']}.png"):
|
|
345
|
+
yield data
|
|
346
|
+
yield _zip.footer()
|
|
347
|
+
|
|
348
|
+
return stream
|