1
|
|
|
# -*- coding: utf-8 -*- |
2
|
|
|
# |
3
|
|
|
# This file is part of SENAITE.CORE. |
4
|
|
|
# |
5
|
|
|
# SENAITE.CORE is free software: you can redistribute it and/or modify it under |
6
|
|
|
# the terms of the GNU General Public License as published by the Free Software |
7
|
|
|
# Foundation, version 2. |
8
|
|
|
# |
9
|
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT |
10
|
|
|
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
11
|
|
|
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
12
|
|
|
# details. |
13
|
|
|
# |
14
|
|
|
# You should have received a copy of the GNU General Public License along with |
15
|
|
|
# this program; if not, write to the Free Software Foundation, Inc., 51 |
16
|
|
|
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
17
|
|
|
# |
18
|
|
|
# Copyright 2018-2025 by it's authors. |
19
|
|
|
# Some rights reserved, see README and LICENSE. |
20
|
|
|
|
21
|
|
|
import re |
22
|
|
|
|
23
|
|
|
from AccessControl import ClassSecurityInfo |
24
|
|
|
from bika.lims import api |
25
|
|
|
from bika.lims import bikaMessageFactory as _ |
26
|
|
|
from bika.lims import logger |
27
|
|
|
from bika.lims.browser.fields import UIDReferenceField |
28
|
|
|
from bika.lims.browser.fields.remarksfield import RemarksField |
29
|
|
|
from bika.lims.browser.widgets import RemarksWidget |
30
|
|
|
from bika.lims.browser.worksheet.tools import getWorksheetLayouts |
31
|
|
|
from bika.lims.config import DEFAULT_WORKSHEET_LAYOUT |
32
|
|
|
from bika.lims.config import PROJECTNAME |
33
|
|
|
from bika.lims.content.bikaschema import BikaSchema |
34
|
|
|
from bika.lims.interfaces import IAnalysisRequest |
35
|
|
|
from bika.lims.interfaces import IDuplicateAnalysis |
36
|
|
|
from bika.lims.interfaces import IReferenceAnalysis |
37
|
|
|
from bika.lims.interfaces import IReferenceSample |
38
|
|
|
from bika.lims.interfaces import IRoutineAnalysis |
39
|
|
|
from bika.lims.interfaces import IWorksheet |
40
|
|
|
from bika.lims.interfaces.analysis import IRequestAnalysis |
41
|
|
|
from bika.lims.utils import changeWorkflowState |
42
|
|
|
from bika.lims.utils import tmpID |
43
|
|
|
from bika.lims.utils import to_int |
44
|
|
|
from bika.lims.utils import to_utf8 as _c |
45
|
|
|
from bika.lims.utils.analysis import create_duplicate |
46
|
|
|
from bika.lims.utils.analysis import create_reference_analysis |
47
|
|
|
from bika.lims.workflow import doActionFor |
48
|
|
|
from bika.lims.workflow import isTransitionAllowed |
49
|
|
|
from bika.lims.workflow import skip |
50
|
|
|
from Products.Archetypes.public import BaseFolder |
51
|
|
|
from Products.Archetypes.public import DisplayList |
52
|
|
|
from Products.Archetypes.public import Schema |
53
|
|
|
from Products.Archetypes.public import SelectionWidget |
54
|
|
|
from Products.Archetypes.public import StringField |
55
|
|
|
from Products.Archetypes.public import registerType |
56
|
|
|
from Products.ATContentTypes.lib.historyaware import HistoryAwareMixin |
57
|
|
|
from Products.CMFCore.utils import getToolByName |
58
|
|
|
from Products.CMFPlone.utils import _createObjectByType |
59
|
|
|
from Products.CMFPlone.utils import safe_unicode |
60
|
|
|
from senaite.core.browser.fields.records import RecordsField |
61
|
|
|
from senaite.core.catalog import ANALYSIS_CATALOG |
62
|
|
|
from senaite.core.idserver import renameAfterCreation |
63
|
|
|
from senaite.core.p3compat import cmp |
64
|
|
|
from senaite.core.permissions.worksheet import can_edit_worksheet |
65
|
|
|
from senaite.core.permissions.worksheet import can_manage_worksheets |
66
|
|
|
from senaite.core.workflow import ANALYSIS_WORKFLOW |
67
|
|
|
from zope.interface import implements |
68
|
|
|
|
69
|
|
|
ALL_ANALYSES_TYPES = "all" |
70
|
|
|
ALLOWED_ANALYSES_TYPES = ["a", "b", "c", "d"] |
71
|
|
|
|
72
|
|
|
|
73
|
|
|
schema = BikaSchema.copy() + Schema(( |
74
|
|
|
|
75
|
|
|
UIDReferenceField( |
76
|
|
|
'WorksheetTemplate', |
77
|
|
|
allowed_types=('WorksheetTemplate',), |
78
|
|
|
), |
79
|
|
|
|
80
|
|
|
RecordsField( |
81
|
|
|
'Layout', |
82
|
|
|
required=1, |
83
|
|
|
subfields=('position', 'type', 'container_uid', 'analysis_uid'), |
84
|
|
|
subfield_types={'position': 'int'}, |
85
|
|
|
), |
86
|
|
|
|
87
|
|
|
# all layout info lives in Layout; Analyses is used for back references. |
88
|
|
|
UIDReferenceField( |
89
|
|
|
'Analyses', |
90
|
|
|
required=1, |
91
|
|
|
multiValued=1, |
92
|
|
|
allowed_types=('Analysis', 'DuplicateAnalysis', 'ReferenceAnalysis', 'RejectAnalysis'), |
93
|
|
|
relationship='WorksheetAnalysis', |
94
|
|
|
), |
95
|
|
|
|
96
|
|
|
StringField( |
97
|
|
|
'Analyst', |
98
|
|
|
searchable=True, |
99
|
|
|
), |
100
|
|
|
|
101
|
|
|
UIDReferenceField( |
102
|
|
|
'Method', |
103
|
|
|
required=0, |
104
|
|
|
vocabulary='_getMethodsVoc', |
105
|
|
|
allowed_types=('Method',), |
106
|
|
|
widget=SelectionWidget( |
107
|
|
|
format='select', |
108
|
|
|
label=_("Method"), |
109
|
|
|
visible=False, |
110
|
|
|
), |
111
|
|
|
), |
112
|
|
|
|
113
|
|
|
# TODO Remove. Instruments must be assigned directly to each analysis. |
114
|
|
|
UIDReferenceField( |
115
|
|
|
'Instrument', |
116
|
|
|
required=0, |
117
|
|
|
allowed_types=('Instrument',), |
118
|
|
|
vocabulary='_getInstrumentsVoc', |
119
|
|
|
), |
120
|
|
|
|
121
|
|
|
RemarksField( |
122
|
|
|
'Remarks', |
123
|
|
|
widget=RemarksWidget( |
124
|
|
|
render_own_label=True, |
125
|
|
|
label=_("Remarks"), |
126
|
|
|
), |
127
|
|
|
), |
128
|
|
|
|
129
|
|
|
StringField( |
130
|
|
|
'ResultsLayout', |
131
|
|
|
default=DEFAULT_WORKSHEET_LAYOUT, |
132
|
|
|
vocabulary=getWorksheetLayouts(), |
133
|
|
|
), |
134
|
|
|
), |
135
|
|
|
) |
136
|
|
|
|
137
|
|
|
schema['id'].required = 0 |
138
|
|
|
schema['id'].widget.visible = False |
139
|
|
|
schema['title'].required = 0 |
140
|
|
|
schema['title'].widget.visible = {'edit': 'hidden', 'view': 'invisible'} |
141
|
|
|
|
142
|
|
|
|
143
|
|
|
class Worksheet(BaseFolder, HistoryAwareMixin): |
144
|
|
|
"""A worksheet is a logical group of Analyses accross ARs |
145
|
|
|
""" |
146
|
|
|
security = ClassSecurityInfo() |
147
|
|
|
implements(IWorksheet) |
148
|
|
|
displayContentsTab = False |
149
|
|
|
schema = schema |
|
|
|
|
150
|
|
|
|
151
|
|
|
_at_rename_after_creation = True |
152
|
|
|
|
153
|
|
|
def _renameAfterCreation(self, check_auto_id=False): |
154
|
|
|
from senaite.core.idserver import renameAfterCreation |
155
|
|
|
renameAfterCreation(self) |
156
|
|
|
|
157
|
|
|
def Title(self): |
158
|
|
|
return safe_unicode(self.getId()).encode('utf-8') |
159
|
|
|
|
160
|
|
|
def setLayout(self, value): |
161
|
|
|
""" |
162
|
|
|
Sets the worksheet layout, keeping it sorted by position |
163
|
|
|
:param value: the layout to set |
164
|
|
|
""" |
165
|
|
|
new_layout = sorted(value, key=lambda k: k['position']) |
166
|
|
|
self.getField('Layout').set(self, new_layout) |
167
|
|
|
|
168
|
|
|
def addAnalyses(self, analyses): |
169
|
|
|
"""Adds a collection of analyses to the Worksheet at once |
170
|
|
|
""" |
171
|
|
|
for analysis in analyses: |
172
|
|
|
self.addAnalysis(api.get_object(analysis)) |
173
|
|
|
|
174
|
|
|
def addAnalysis(self, analysis, position=None): |
175
|
|
|
"""- add the analysis to self.Analyses(). |
176
|
|
|
- position is overruled if a slot for this analysis' parent exists |
177
|
|
|
- if position is None, next available pos is used. |
178
|
|
|
""" |
179
|
|
|
# Cannot add an analysis if not open, unless a retest |
180
|
|
|
if api.get_review_status(self) not in ["open", "to_be_verified"]: |
181
|
|
|
retracted = analysis.getRetestOf() |
182
|
|
|
if retracted not in self.getAnalyses(): |
183
|
|
|
return |
184
|
|
|
|
185
|
|
|
# Cannot add an analysis that is assigned already |
186
|
|
|
if analysis.getWorksheet(): |
187
|
|
|
return |
188
|
|
|
|
189
|
|
|
# Just in case |
190
|
|
|
analyses = self.getAnalyses() |
191
|
|
|
if analysis in analyses: |
192
|
|
|
analyses = filter(lambda an: an != analysis, analyses) |
193
|
|
|
self.setAnalyses(analyses) |
194
|
|
|
self.updateLayout() |
195
|
|
|
|
196
|
|
|
# Cannot add an analysis if the assign transition is not possible |
197
|
|
|
# We need to bypass the guard's check for current context! |
198
|
|
|
api.get_request().set("ws_uid", api.get_uid(self)) |
199
|
|
|
if not isTransitionAllowed(analysis, "assign"): |
200
|
|
|
return |
201
|
|
|
|
202
|
|
|
# Assign the instrument from the worksheet to the analysis, if possible |
203
|
|
|
instrument = self.getInstrument() |
204
|
|
|
if instrument and analysis.isInstrumentAllowed(instrument): |
205
|
|
|
# TODO Analysis Instrument + Method assignment |
206
|
|
|
methods = instrument.getMethods() |
207
|
|
|
if methods: |
208
|
|
|
# Set the first method assigned to the selected instrument |
209
|
|
|
analysis.setMethod(methods[0]) |
210
|
|
|
analysis.setInstrument(instrument) |
211
|
|
|
elif not instrument: |
212
|
|
|
# If the ws doesn't have an instrument try to set the method |
213
|
|
|
method = self.getMethod() |
214
|
|
|
if method and analysis.isMethodAllowed(method): |
215
|
|
|
analysis.setMethod(method) |
216
|
|
|
|
217
|
|
|
# Assign the worksheet's analyst to the analysis |
218
|
|
|
# https://github.com/senaite/senaite.core/issues/1409 |
219
|
|
|
analysis.setAnalyst(self.getAnalyst()) |
220
|
|
|
|
221
|
|
|
# Transition analysis to "assigned" |
222
|
|
|
doActionFor(analysis, "assign") |
223
|
|
|
self.setAnalyses(analyses + [analysis]) |
224
|
|
|
self.addToLayout(analysis, position) |
225
|
|
|
|
226
|
|
|
# Try to rollback the worksheet to prevent inconsistencies |
227
|
|
|
doActionFor(self, "rollback_to_open") |
228
|
|
|
|
229
|
|
|
# Reindex Analysis |
230
|
|
|
analysis.reindexObject() |
231
|
|
|
|
232
|
|
|
# Reindex Worksheet |
233
|
|
|
self.reindexObject() |
234
|
|
|
|
235
|
|
|
# Reindex Analysis Request, if any |
236
|
|
|
if IRequestAnalysis.providedBy(analysis): |
237
|
|
|
analysis.getRequest().reindexObject() |
238
|
|
|
|
239
|
|
|
def removeAnalysis(self, analysis): |
240
|
|
|
""" Unassigns the analysis passed in from the worksheet. |
241
|
|
|
Delegates to 'unassign' transition for the analysis passed in |
242
|
|
|
""" |
243
|
|
|
# We need to bypass the guard's check for current context! |
244
|
|
|
api.get_request().set("ws_uid", api.get_uid(self)) |
245
|
|
|
if analysis.getWorksheet() == self: |
246
|
|
|
doActionFor(analysis, "unassign") |
247
|
|
|
|
248
|
|
|
def addToLayout(self, analysis, position=None): |
249
|
|
|
""" Adds the analysis passed in to the worksheet's layout |
250
|
|
|
""" |
251
|
|
|
# TODO Redux |
252
|
|
|
layout = self.getLayout() |
253
|
|
|
container_uid = self.get_container_for(analysis) |
254
|
|
|
if IRequestAnalysis.providedBy(analysis) and \ |
255
|
|
|
not IDuplicateAnalysis.providedBy(analysis): |
256
|
|
|
container_uids = map(lambda slot: slot['container_uid'], layout) |
257
|
|
|
if container_uid in container_uids: |
258
|
|
|
position = [int(slot['position']) for slot in layout if |
259
|
|
|
slot['container_uid'] == container_uid][0] |
260
|
|
|
elif not position: |
261
|
|
|
used_positions = [0, ] + [int(slot['position']) for slot in |
262
|
|
|
layout] |
263
|
|
|
position = [pos for pos in range(1, max(used_positions) + 2) |
264
|
|
|
if pos not in used_positions][0] |
265
|
|
|
|
266
|
|
|
an_type = self.get_analysis_type(analysis) |
267
|
|
|
self.setLayout(layout + [{'position': position, |
268
|
|
|
'type': an_type, |
269
|
|
|
'container_uid': container_uid, |
270
|
|
|
'analysis_uid': api.get_uid(analysis)}, ]) |
271
|
|
|
|
272
|
|
|
def purgeLayout(self): |
273
|
|
|
""" Purges the layout of not assigned analyses |
274
|
|
|
""" |
275
|
|
|
uids = map(api.get_uid, self.getAnalyses()) |
276
|
|
|
layout = filter(lambda slot: slot.get("analysis_uid", None) in uids, |
277
|
|
|
self.getLayout()) |
278
|
|
|
self.setLayout(layout) |
279
|
|
|
|
280
|
|
|
def _getMethodsVoc(self): |
281
|
|
|
""" |
282
|
|
|
This function returns the registered methods in the system as a |
283
|
|
|
vocabulary. |
284
|
|
|
""" |
285
|
|
|
bsc = getToolByName(self, 'senaite_catalog_setup') |
286
|
|
|
items = [(i.UID, i.Title) |
287
|
|
|
for i in bsc(portal_type='Method', |
288
|
|
|
is_active=True)] |
289
|
|
|
items.sort(lambda x, y: cmp(x[1], y[1])) |
290
|
|
|
items.insert(0, ('', _("Not specified"))) |
291
|
|
|
return DisplayList(list(items)) |
292
|
|
|
|
293
|
|
|
def _getInstrumentsVoc(self): |
294
|
|
|
""" |
295
|
|
|
This function returns the registered instruments in the system as a |
296
|
|
|
vocabulary. The instruments are filtered by the selected method. |
297
|
|
|
""" |
298
|
|
|
cfilter = {'portal_type': 'Instrument', 'is_active': True} |
299
|
|
|
if self.getMethod(): |
300
|
|
|
cfilter['getMethodUIDs'] = {"query": self.getMethod().UID(), |
301
|
|
|
"operator": "or"} |
302
|
|
|
bsc = getToolByName(self, 'senaite_catalog_setup') |
303
|
|
|
items = [('', 'No instrument')] + [ |
304
|
|
|
(o.UID, o.Title) for o in |
305
|
|
|
bsc(cfilter)] |
306
|
|
|
o = self.getInstrument() |
307
|
|
|
if o and o.UID() not in [i[0] for i in items]: |
308
|
|
|
items.append((o.UID(), o.Title())) |
309
|
|
|
items.sort(lambda x, y: cmp(x[1], y[1])) |
310
|
|
|
return DisplayList(list(items)) |
311
|
|
|
|
312
|
|
|
def addReferenceAnalyses(self, reference, services, slot=None): |
313
|
|
|
""" Creates and add reference analyses to the slot by using the |
314
|
|
|
reference sample and service uids passed in. |
315
|
|
|
If no destination slot is defined, the most suitable slot will be used, |
316
|
|
|
typically a new slot at the end of the worksheet will be added. |
317
|
|
|
:param reference: reference sample to which ref analyses belong |
318
|
|
|
:param service_uids: he uid of the services to create analyses from |
319
|
|
|
:param slot: slot where reference analyses must be stored |
320
|
|
|
:return: the list of reference analyses added |
321
|
|
|
""" |
322
|
|
|
service_uids = list() |
323
|
|
|
for service in services: |
324
|
|
|
if api.is_uid(service): |
325
|
|
|
service_uids.append(service) |
326
|
|
|
else: |
327
|
|
|
service_uids.append(api.get_uid(service)) |
328
|
|
|
service_uids = list(set(service_uids)) |
329
|
|
|
|
330
|
|
|
# Cannot add a reference analysis if not open |
331
|
|
|
if api.get_workflow_status_of(self) != "open": |
332
|
|
|
return [] |
333
|
|
|
|
334
|
|
|
slot_to = to_int(slot) |
335
|
|
|
if slot_to < 0: |
336
|
|
|
return [] |
337
|
|
|
|
338
|
|
|
if not slot_to: |
339
|
|
|
# Find the suitable slot to add these references |
340
|
|
|
slot_to = self.get_suitable_slot_for_reference(reference) |
341
|
|
|
return self.addReferenceAnalyses(reference, service_uids, slot_to) |
342
|
|
|
|
343
|
|
|
processed = list() |
344
|
|
|
for analysis in self.get_analyses_at(slot_to): |
345
|
|
|
if api.get_review_status(analysis) != "retracted": |
346
|
|
|
service = analysis.getAnalysisService() |
347
|
|
|
processed.append(api.get_uid(service)) |
348
|
|
|
query = dict(portal_type="AnalysisService", UID=service_uids, |
349
|
|
|
sort_on="sortable_title") |
350
|
|
|
services = filter(lambda service: api.get_uid(service) not in processed, |
351
|
|
|
api.search(query, "senaite_catalog_setup")) |
352
|
|
|
|
353
|
|
|
# Ref analyses from the same slot must have the same group id |
354
|
|
|
ref_gid = self.nextRefAnalysesGroupID(reference) |
355
|
|
|
ref_analyses = list() |
356
|
|
|
for service in services: |
357
|
|
|
service_obj = api.get_object(service) |
358
|
|
|
ref_analysis = self.add_reference_analysis(reference, service_obj, |
359
|
|
|
slot_to, ref_gid) |
360
|
|
|
if not ref_analysis: |
361
|
|
|
continue |
362
|
|
|
ref_analyses.append(ref_analysis) |
363
|
|
|
return ref_analyses |
364
|
|
|
|
365
|
|
|
def add_reference_analysis(self, reference, service, slot, ref_gid=None): |
366
|
|
|
""" |
367
|
|
|
Creates a reference analysis in the destination slot (dest_slot) passed |
368
|
|
|
in, by using the reference and service_uid. If the analysis |
369
|
|
|
passed in is not an IReferenceSample or has dependent services, returns |
370
|
|
|
None. If no reference analyses group id (refgid) is set, the value will |
371
|
|
|
be generated automatically. |
372
|
|
|
:param reference: reference sample to create an analysis from |
373
|
|
|
:param service: the service object to create an analysis from |
374
|
|
|
:param slot: slot where the reference analysis must be stored |
375
|
|
|
:param refgid: the reference analyses group id to be set |
376
|
|
|
:return: the reference analysis or None |
377
|
|
|
""" |
378
|
|
|
if not reference or not service: |
379
|
|
|
return None |
380
|
|
|
|
381
|
|
|
if not IReferenceSample.providedBy(reference): |
382
|
|
|
logger.warning('Cannot create reference analysis from a non ' |
383
|
|
|
'reference sample: {}'.format(reference.getId())) |
384
|
|
|
return None |
385
|
|
|
|
386
|
|
|
calc = service.getCalculation() |
387
|
|
|
if calc and calc.getDependentServices(): |
388
|
|
|
logger.warning('Cannot create reference analyses with dependent' |
389
|
|
|
'services: {}'.format(service.getId())) |
390
|
|
|
return None |
391
|
|
|
|
392
|
|
|
# Create the reference analysis |
393
|
|
|
gid = ref_gid and ref_gid or self.nextRefAnalysesGroupID(reference) |
394
|
|
|
values = {"ReferenceAnalysesGroupID": gid, "Worksheet": self} |
395
|
|
|
ref_analysis = create_reference_analysis(reference, service, **values) |
396
|
|
|
|
397
|
|
|
# Add the reference analysis into the worksheet |
398
|
|
|
self.setAnalyses(self.getAnalyses() + [ref_analysis, ]) |
399
|
|
|
self.addToLayout(ref_analysis, slot) |
400
|
|
|
|
401
|
|
|
# TODO This shuldn't be necessary, but `getWorksheetUID` relies on |
402
|
|
|
# backreference, while it should be the other way round. |
403
|
|
|
# `getAnalyst` is affected as well, because in turn, it relies |
404
|
|
|
# on `getWorksheet` to get the assigned analyst. |
405
|
|
|
ref_analysis.reindexObject(idxs=[ |
406
|
|
|
"getWorksheetUID", |
407
|
|
|
"getAnalyst", |
408
|
|
|
"getReferenceAnalysesGroupID", # used in `nextRefAnalysesGroupID` |
409
|
|
|
]) |
410
|
|
|
|
411
|
|
|
# Reindex |
412
|
|
|
self.reindexObject(idxs=["getAnalysesUIDs"]) |
413
|
|
|
return ref_analysis |
414
|
|
|
|
415
|
|
|
def nextRefAnalysesGroupID(self, reference): |
416
|
|
|
""" Returns the next ReferenceAnalysesGroupID for the given reference |
417
|
|
|
sample. Gets the last reference analysis registered in the system |
418
|
|
|
for the specified reference sample and increments in one unit the |
419
|
|
|
suffix. |
420
|
|
|
""" |
421
|
|
|
# TODO This hurts my eyes, @xispa cleanup this RefAnalysesGroupID asap |
422
|
|
|
prefix = reference.id + "-" |
423
|
|
|
if not IReferenceSample.providedBy(reference): |
424
|
|
|
# Not a ReferenceSample, so this is a duplicate |
425
|
|
|
prefix = reference.id + "-D" |
426
|
|
|
cat = api.get_tool(ANALYSIS_CATALOG) |
427
|
|
|
ids = cat.Indexes["getReferenceAnalysesGroupID"].uniqueValues() |
428
|
|
|
rr = re.compile("^" + prefix + r"[\d+]+$") |
429
|
|
|
ids = [int(i.split(prefix)[1]) for i in ids if i and rr.match(i)] |
430
|
|
|
ids.sort() |
431
|
|
|
_id = ids[-1] if ids else 0 |
432
|
|
|
suffix = str(_id + 1).zfill(int(3)) |
433
|
|
|
if not IReferenceSample.providedBy(reference): |
434
|
|
|
# Not a ReferenceSample, so this is a duplicate |
435
|
|
|
suffix = str(_id + 1).zfill(2) |
436
|
|
|
return '%s%s' % (prefix, suffix) |
437
|
|
|
|
438
|
|
|
def addDuplicateAnalyses(self, src_slot, dest_slot=None): |
439
|
|
|
""" Creates and add duplicate analyes from the src_slot to the dest_slot |
440
|
|
|
If no destination slot is defined, the most suitable slot will be used, |
441
|
|
|
typically a new slot at the end of the worksheet will be added. |
442
|
|
|
:param src_slot: slot that contains the analyses to duplicate |
443
|
|
|
:param dest_slot: slot where the duplicate analyses must be stored |
444
|
|
|
:return: the list of duplicate analyses added |
445
|
|
|
""" |
446
|
|
|
# Duplicate analyses can only be added if the state of the ws is open |
447
|
|
|
# unless we are adding a retest |
448
|
|
|
if api.get_workflow_status_of(self) != "open": |
449
|
|
|
return [] |
450
|
|
|
|
451
|
|
|
slot_from = to_int(src_slot, 0) |
452
|
|
|
if slot_from < 1: |
453
|
|
|
return [] |
454
|
|
|
|
455
|
|
|
slot_to = to_int(dest_slot, 0) |
456
|
|
|
if slot_to < 0: |
457
|
|
|
return [] |
458
|
|
|
|
459
|
|
|
if not slot_to: |
460
|
|
|
# Find the suitable slot to add these duplicates |
461
|
|
|
slot_to = self.get_suitable_slot_for_duplicate(slot_from) |
462
|
|
|
return self.addDuplicateAnalyses(src_slot, slot_to) |
463
|
|
|
|
464
|
|
|
processed = map(lambda an: api.get_uid(an.getAnalysis()), |
465
|
|
|
self.get_analyses_at(slot_to)) |
466
|
|
|
src_analyses = list() |
467
|
|
|
for analysis in self.get_analyses_at(slot_from): |
468
|
|
|
if api.get_uid(analysis) in processed: |
469
|
|
|
if api.get_workflow_status_of(analysis) != "retracted": |
470
|
|
|
continue |
471
|
|
|
src_analyses.append(analysis) |
472
|
|
|
ref_gid = None |
473
|
|
|
duplicates = list() |
474
|
|
|
for analysis in src_analyses: |
475
|
|
|
duplicate = self.add_duplicate_analysis(analysis, slot_to, ref_gid) |
476
|
|
|
if not duplicate: |
477
|
|
|
continue |
478
|
|
|
# All duplicates from the same slot must have the same group id |
479
|
|
|
ref_gid = ref_gid or duplicate.getReferenceAnalysesGroupID() |
480
|
|
|
duplicates.append(duplicate) |
481
|
|
|
return duplicates |
482
|
|
|
|
483
|
|
|
def add_duplicate_analysis(self, src_analysis, destination_slot, |
484
|
|
|
ref_gid=None): |
485
|
|
|
""" |
486
|
|
|
Creates a duplicate of the src_analysis passed in. If the analysis |
487
|
|
|
passed in is not an IRoutineAnalysis, is retracted or has dependent |
488
|
|
|
services, returns None.If no reference analyses group id (ref_gid) is |
489
|
|
|
set, the value will be generated automatically. |
490
|
|
|
:param src_analysis: analysis to create a duplicate from |
491
|
|
|
:param destination_slot: slot where duplicate analysis must be stored |
492
|
|
|
:param ref_gid: the reference analysis group id to be set |
493
|
|
|
:return: the duplicate analysis or None |
494
|
|
|
""" |
495
|
|
|
if not src_analysis: |
496
|
|
|
return None |
497
|
|
|
|
498
|
|
|
if not IRoutineAnalysis.providedBy(src_analysis): |
499
|
|
|
logger.warning('Cannot create duplicate analysis from a non ' |
500
|
|
|
'routine analysis: {}'.format(src_analysis.getId())) |
501
|
|
|
return None |
502
|
|
|
|
503
|
|
|
if api.get_review_status(src_analysis) == 'retracted': |
504
|
|
|
logger.warning('Cannot create duplicate analysis from a retracted' |
505
|
|
|
'analysis: {}'.format(src_analysis.getId())) |
506
|
|
|
return None |
507
|
|
|
|
508
|
|
|
# TODO Workflow - Duplicate Analyses - Consider duplicates with deps |
509
|
|
|
# Removing this check from here and ensuring that duplicate.getSiblings |
510
|
|
|
# returns the analyses sorted by priority (duplicates from same |
511
|
|
|
# AR > routine analyses from same AR > duplicates from same WS > |
512
|
|
|
# routine analyses from same WS) should be almost enough |
513
|
|
|
calc = src_analysis.getCalculation() |
514
|
|
|
if calc and calc.getDependentServices(): |
515
|
|
|
logger.warning('Cannot create duplicate analysis from an' |
516
|
|
|
'analysis with dependent services: {}' |
517
|
|
|
.format(src_analysis.getId())) |
518
|
|
|
return None |
519
|
|
|
|
520
|
|
|
# Create the duplicate |
521
|
|
|
duplicate = create_duplicate(src_analysis) |
522
|
|
|
|
523
|
|
|
# Add the duplicate into the worksheet |
524
|
|
|
self.addToLayout(duplicate, destination_slot) |
525
|
|
|
self.setAnalyses(self.getAnalyses() + [duplicate, ]) |
526
|
|
|
|
527
|
|
|
# TODO This shuldn't be necessary, but `getWorksheetUID` relies on |
528
|
|
|
# backreference, while it should be the other way round. |
529
|
|
|
# `getAnalyst` is affected as well, because in turn, it relies |
530
|
|
|
# on `getWorksheet` to get the assigned analyst. |
531
|
|
|
duplicate.reindexObject(idxs=["getWorksheetUID", "getAnalyst"]) |
532
|
|
|
|
533
|
|
|
# Reindex |
534
|
|
|
self.reindexObject(idxs=["getAnalysesUIDs"]) |
535
|
|
|
return duplicate |
536
|
|
|
|
537
|
|
|
def get_suitable_slot_for_duplicate(self, src_slot): |
538
|
|
|
"""Returns the suitable position for a duplicate analysis, taking into |
539
|
|
|
account if there is a WorksheetTemplate assigned to this worksheet. |
540
|
|
|
|
541
|
|
|
By default, returns a new slot at the end of the worksheet unless there |
542
|
|
|
is a slot defined for a duplicate of the src_slot in the worksheet |
543
|
|
|
template layout not yet used. |
544
|
|
|
|
545
|
|
|
:param src_slot: |
546
|
|
|
:return: suitable slot position for a duplicate of src_slot |
547
|
|
|
""" |
548
|
|
|
slot_from = to_int(src_slot, 0) |
549
|
|
|
if slot_from < 1: |
550
|
|
|
return -1 |
551
|
|
|
|
552
|
|
|
# Are the analyses from src_slot suitable for duplicates creation? |
553
|
|
|
container = self.get_container_at(slot_from) |
554
|
|
|
if not container or not IAnalysisRequest.providedBy(container): |
555
|
|
|
# We cannot create duplicates from analyses other than routine ones, |
556
|
|
|
# those that belong to an Analysis Request. |
557
|
|
|
return -1 |
558
|
|
|
|
559
|
|
|
occupied = self.get_slot_positions(type='all') |
560
|
|
|
wst = self.getWorksheetTemplate() |
561
|
|
|
if not wst: |
562
|
|
|
# No worksheet template assigned, add a new slot at the end of |
563
|
|
|
# the worksheet with the duplicate there |
564
|
|
|
slot_to = max(occupied) + 1 |
565
|
|
|
return slot_to |
566
|
|
|
|
567
|
|
|
# If there is a match with the layout defined in the Worksheet |
568
|
|
|
# Template, use that slot instead of adding a new one at the end of |
569
|
|
|
# the worksheet |
570
|
|
|
layout = wst.getTemplateLayout() |
571
|
|
|
for pos in layout: |
572
|
|
|
if pos['type'] != 'd' or to_int(pos['dup']) != slot_from: |
573
|
|
|
continue |
574
|
|
|
slot_to = int(pos['pos']) |
575
|
|
|
if slot_to in occupied: |
576
|
|
|
# Not an empty slot |
577
|
|
|
continue |
578
|
|
|
|
579
|
|
|
# This slot is empty, use it instead of adding a new |
580
|
|
|
# slot at the end of the worksheet |
581
|
|
|
return slot_to |
582
|
|
|
|
583
|
|
|
# Add a new slot at the end of the worksheet, but take into account |
584
|
|
|
# that a worksheet template is assigned, so we need to take care to |
585
|
|
|
# not override slots defined by its layout |
586
|
|
|
occupied.append(len(layout)) |
587
|
|
|
slot_to = max(occupied) + 1 |
588
|
|
|
return slot_to |
589
|
|
|
|
590
|
|
|
def get_suitable_slot_for_reference(self, reference): |
591
|
|
|
"""Returns the suitable position for reference analyses, taking into |
592
|
|
|
account if there is a WorksheetTemplate assigned to this worksheet. |
593
|
|
|
|
594
|
|
|
By default, returns a new slot at the end of the worksheet unless there |
595
|
|
|
is a slot defined for a reference of the same type (blank or control) |
596
|
|
|
in the worksheet template's layout that hasn't been used yet. |
597
|
|
|
|
598
|
|
|
:param reference: ReferenceSample the analyses will be created from |
599
|
|
|
:return: suitable slot position for reference analyses |
600
|
|
|
""" |
601
|
|
|
if not IReferenceSample.providedBy(reference): |
602
|
|
|
return -1 |
603
|
|
|
|
604
|
|
|
occupied = self.get_slot_positions(type='all') or [0] |
605
|
|
|
wst = self.getWorksheetTemplate() |
606
|
|
|
if not wst: |
607
|
|
|
# No worksheet template assigned, add a new slot at the end of the |
608
|
|
|
# worksheet with the reference analyses there |
609
|
|
|
slot_to = max(occupied) + 1 |
610
|
|
|
return slot_to |
611
|
|
|
|
612
|
|
|
# If there is a match with the layout defined in the Worksheet Template, |
613
|
|
|
# use that slot instead of adding a new one at the end of the worksheet |
614
|
|
|
slot_type = reference.getBlank() and 'b' or 'c' |
615
|
|
|
layout = wst.getTemplateLayout() |
616
|
|
|
|
617
|
|
|
for pos in layout: |
618
|
|
|
if pos['type'] != slot_type: |
619
|
|
|
continue |
620
|
|
|
slot_to = int(pos['pos']) |
621
|
|
|
if slot_to in occupied: |
622
|
|
|
# Not an empty slot |
623
|
|
|
continue |
624
|
|
|
|
625
|
|
|
# This slot is empty, use it instead of adding a new slot at the end |
626
|
|
|
# of the worksheet |
627
|
|
|
return slot_to |
628
|
|
|
|
629
|
|
|
# Add a new slot at the end of the worksheet, but take into account |
630
|
|
|
# that a worksheet template is assigned, so we need to take care to |
631
|
|
|
# not override slots defined by its layout |
632
|
|
|
occupied.append(len(layout)) |
633
|
|
|
slot_to = max(occupied) + 1 |
634
|
|
|
return slot_to |
635
|
|
|
|
636
|
|
|
def get_duplicates_for(self, analysis): |
637
|
|
|
"""Returns the duplicates from the current worksheet that were created |
638
|
|
|
by using the analysis passed in as the source |
639
|
|
|
|
640
|
|
|
:param analysis: routine analyses used as the source for the duplicates |
641
|
|
|
:return: a list of duplicates generated from the analysis passed in |
642
|
|
|
""" |
643
|
|
|
if not analysis: |
644
|
|
|
return list() |
645
|
|
|
uid = api.get_uid(analysis) |
646
|
|
|
return filter(lambda dup: api.get_uid(dup.getAnalysis()) == uid, |
|
|
|
|
647
|
|
|
self.getDuplicateAnalyses()) |
648
|
|
|
|
649
|
|
|
def get_analyses_at(self, slot): |
650
|
|
|
"""Returns the list of analyses assigned to the slot passed in, sorted by |
651
|
|
|
the positions they have within the slot. |
652
|
|
|
|
653
|
|
|
:param slot: the slot where the analyses are located |
654
|
|
|
:type slot: int |
655
|
|
|
:return: a list of analyses |
656
|
|
|
""" |
657
|
|
|
|
658
|
|
|
# ensure we have an integer |
659
|
|
|
slot = to_int(slot) |
660
|
|
|
|
661
|
|
|
if slot < 1: |
662
|
|
|
return list() |
663
|
|
|
|
664
|
|
|
analyses = list() |
665
|
|
|
layout = self.getLayout() |
666
|
|
|
|
667
|
|
|
for pos in layout: |
668
|
|
|
layout_slot = to_int(pos['position']) |
669
|
|
|
uid = pos['analysis_uid'] |
670
|
|
|
if layout_slot != slot or not uid: |
671
|
|
|
continue |
672
|
|
|
analyses.append(api.get_object_by_uid(uid)) |
673
|
|
|
|
674
|
|
|
return analyses |
675
|
|
|
|
676
|
|
|
def get_container_at(self, slot): |
677
|
|
|
"""Returns the container object assigned to the slot passed in |
678
|
|
|
|
679
|
|
|
:param slot: the slot where the analyses are located |
680
|
|
|
:type slot: int |
681
|
|
|
:return: the container (analysis request, reference sample, etc.) |
682
|
|
|
""" |
683
|
|
|
|
684
|
|
|
# ensure we have an integer |
685
|
|
|
slot = to_int(slot) |
686
|
|
|
|
687
|
|
|
if slot < 1: |
688
|
|
|
return None |
689
|
|
|
|
690
|
|
|
layout = self.getLayout() |
691
|
|
|
|
692
|
|
|
for pos in layout: |
693
|
|
|
layout_slot = to_int(pos['position']) |
694
|
|
|
uid = pos['container_uid'] |
695
|
|
|
if layout_slot != slot or not uid: |
696
|
|
|
continue |
697
|
|
|
return api.get_object_by_uid(uid) |
698
|
|
|
|
699
|
|
|
return None |
700
|
|
|
|
701
|
|
|
def get_slot_positions(self, type='a'): |
702
|
|
|
"""Returns a list with the slots occupied for the type passed in. |
703
|
|
|
|
704
|
|
|
Allowed type of analyses are: |
705
|
|
|
|
706
|
|
|
'a' (routine analysis) |
707
|
|
|
'b' (blank analysis) |
708
|
|
|
'c' (control) |
709
|
|
|
'd' (duplicate) |
710
|
|
|
'all' (all analyses) |
711
|
|
|
|
712
|
|
|
:param type: type of the analysis |
713
|
|
|
:return: list of slot positions |
714
|
|
|
""" |
715
|
|
|
if type not in ALLOWED_ANALYSES_TYPES and type != ALL_ANALYSES_TYPES: |
716
|
|
|
return list() |
717
|
|
|
|
718
|
|
|
layout = self.getLayout() |
719
|
|
|
slots = list() |
720
|
|
|
|
721
|
|
|
for pos in layout: |
722
|
|
|
if type != ALL_ANALYSES_TYPES and pos['type'] != type: |
723
|
|
|
continue |
724
|
|
|
slots.append(to_int(pos['position'])) |
725
|
|
|
|
726
|
|
|
# return a unique list of sorted slot positions |
727
|
|
|
return sorted(set(slots)) |
728
|
|
|
|
729
|
|
|
def get_slot_position(self, container, type='a'): |
730
|
|
|
"""Returns the slot where the analyses from the type and container passed |
731
|
|
|
in are located within the worksheet. |
732
|
|
|
|
733
|
|
|
:param container: the container in which the analyses are grouped |
734
|
|
|
:param type: type of the analysis |
735
|
|
|
:return: the slot position |
736
|
|
|
:rtype: int |
737
|
|
|
""" |
738
|
|
|
if not container or type not in ALLOWED_ANALYSES_TYPES: |
739
|
|
|
return None |
740
|
|
|
uid = api.get_uid(container) |
741
|
|
|
layout = self.getLayout() |
742
|
|
|
|
743
|
|
|
for pos in layout: |
744
|
|
|
if pos['type'] != type or pos['container_uid'] != uid: |
745
|
|
|
continue |
746
|
|
|
return to_int(pos['position']) |
747
|
|
|
return None |
748
|
|
|
|
749
|
|
|
def get_analysis_type(self, instance): |
750
|
|
|
"""Returns the string used in slots to differentiate amongst analysis |
751
|
|
|
types |
752
|
|
|
""" |
753
|
|
|
if IDuplicateAnalysis.providedBy(instance): |
754
|
|
|
return 'd' |
755
|
|
|
elif IReferenceAnalysis.providedBy(instance): |
756
|
|
|
return instance.getReferenceType() |
757
|
|
|
elif IRoutineAnalysis.providedBy(instance): |
758
|
|
|
return 'a' |
759
|
|
|
return None |
760
|
|
|
|
761
|
|
|
def get_container_for(self, instance): |
762
|
|
|
"""Returns the container id used in slots to group analyses |
763
|
|
|
""" |
764
|
|
|
if IReferenceAnalysis.providedBy(instance): |
765
|
|
|
return api.get_uid(instance.getSample()) |
766
|
|
|
return instance.getRequestUID() |
767
|
|
|
|
768
|
|
|
def get_slot_position_for(self, instance): |
769
|
|
|
"""Returns the slot where the instance passed in is located. If not |
770
|
|
|
found, returns None |
771
|
|
|
""" |
772
|
|
|
uid = api.get_uid(instance) |
773
|
|
|
slot = filter(lambda s: s['analysis_uid'] == uid, self.getLayout()) |
774
|
|
|
if not slot: |
775
|
|
|
return None |
776
|
|
|
return to_int(slot[0]['position']) |
777
|
|
|
|
778
|
|
|
def resolve_available_slots(self, worksheet_template, type='a'): |
779
|
|
|
"""Returns the available slots from the current worksheet that fits |
780
|
|
|
with the layout defined in the worksheet_template and type of analysis |
781
|
|
|
passed in. |
782
|
|
|
|
783
|
|
|
Allowed type of analyses are: |
784
|
|
|
|
785
|
|
|
'a' (routine analysis) |
786
|
|
|
'b' (blank analysis) |
787
|
|
|
'c' (control) |
788
|
|
|
'd' (duplicate) |
789
|
|
|
|
790
|
|
|
:param worksheet_template: the worksheet template to match against |
791
|
|
|
:param type: type of analyses to restrict that suit with the slots |
792
|
|
|
:return: a list of slots positions |
793
|
|
|
""" |
794
|
|
|
if not worksheet_template or type not in ALLOWED_ANALYSES_TYPES: |
795
|
|
|
return list() |
796
|
|
|
|
797
|
|
|
ws_slots = self.get_slot_positions(type) |
798
|
|
|
layout = worksheet_template.getTemplateLayout() |
799
|
|
|
slots = list() |
800
|
|
|
|
801
|
|
|
for row in layout: |
802
|
|
|
# skip rows that do not match with the given type |
803
|
|
|
if row['type'] != type: |
804
|
|
|
continue |
805
|
|
|
|
806
|
|
|
slot = to_int(row['pos']) |
807
|
|
|
|
808
|
|
|
if slot in ws_slots: |
809
|
|
|
# We only want those that are empty |
810
|
|
|
continue |
811
|
|
|
|
812
|
|
|
slots.append(slot) |
813
|
|
|
return slots |
814
|
|
|
|
815
|
|
|
def get_containers_slots(self): |
816
|
|
|
"""Returns a list of tuple (container_uid, slot) |
817
|
|
|
""" |
818
|
|
|
layout = self.getLayout() |
819
|
|
|
return map(lambda l: (l["container_uid"], int(l["position"])), layout) |
820
|
|
|
|
821
|
|
|
def _apply_worksheet_template_routine_analyses(self, wst, analyses=None): |
822
|
|
|
"""Add routine analyses to worksheet according to the worksheet template |
823
|
|
|
layout passed in w/o overwriting slots that are already filled. |
824
|
|
|
|
825
|
|
|
If the template passed in has an instrument assigned, only those |
826
|
|
|
routine analyses that allows the instrument will be added. |
827
|
|
|
|
828
|
|
|
If the template passed in has a method assigned, only those routine |
829
|
|
|
analyses that allows the method will be added |
830
|
|
|
|
831
|
|
|
:param wst: worksheet template used as the layout |
832
|
|
|
:param analyses: list of analyses |
833
|
|
|
:returns: None |
834
|
|
|
""" |
835
|
|
|
# Get the services from the Worksheet Template |
836
|
|
|
service_uids = wst.getRawServices() |
837
|
|
|
if not service_uids: |
838
|
|
|
# No service uids assigned to this Worksheet Template, skip |
839
|
|
|
logger.warn("Worksheet Template {} has no services assigned" |
840
|
|
|
.format(api.get_path(wst))) |
841
|
|
|
return |
842
|
|
|
|
843
|
|
|
if analyses is None: |
844
|
|
|
# Search for unassigned analyses |
845
|
|
|
query = { |
846
|
|
|
"portal_type": "Analysis", |
847
|
|
|
"getServiceUID": service_uids, |
848
|
|
|
"review_state": "unassigned", |
849
|
|
|
"sort_on": "getPrioritySortkey" |
850
|
|
|
} |
851
|
|
|
analyses = api.search(query, ANALYSIS_CATALOG) |
852
|
|
|
if not analyses: |
853
|
|
|
return |
854
|
|
|
else: |
855
|
|
|
assignable_analyses = [] |
856
|
|
|
# filter assigned analyses and those that do not belong to the WST |
857
|
|
|
for analysis in analyses: |
858
|
|
|
analysis = api.get_object(analysis) |
859
|
|
|
# analysis must be unassigned |
860
|
|
|
if analysis.getWorksheetUID(): |
861
|
|
|
continue |
862
|
|
|
service_uid = analysis.getRawAnalysisService() |
863
|
|
|
# analysis must belong to the services of the WST |
864
|
|
|
if service_uid not in service_uids: |
865
|
|
|
continue |
866
|
|
|
assignable_analyses.append(analysis) |
867
|
|
|
analyses = assignable_analyses |
868
|
|
|
|
869
|
|
|
# Available slots for routine analyses |
870
|
|
|
available_slots = self.resolve_available_slots(wst, "a") |
871
|
|
|
available_slots.sort(reverse=True) |
872
|
|
|
|
873
|
|
|
# If there is an instrument assigned to this Worksheet Template, take |
874
|
|
|
# only the analyses that allow this instrument into consideration. |
875
|
|
|
instrument = wst.getRawInstrument() |
876
|
|
|
|
877
|
|
|
# If there is method assigned to the Worksheet Template, take only the |
878
|
|
|
# analyses that allow this method into consideration. |
879
|
|
|
method = wst.getRawRestrictToMethod() |
880
|
|
|
|
881
|
|
|
# Map existing sample uids with slots |
882
|
|
|
samples_slots = dict(self.get_containers_slots()) |
883
|
|
|
new_sample_uids = [] |
884
|
|
|
new_analyses = [] |
885
|
|
|
|
886
|
|
|
for analysis in analyses: |
887
|
|
|
analysis = api.get_object(analysis) |
888
|
|
|
|
889
|
|
|
if instrument and not analysis.isInstrumentAllowed(instrument): |
890
|
|
|
# WST's Instrument does not supports this analysis |
891
|
|
|
continue |
892
|
|
|
|
893
|
|
|
if method and not analysis.isMethodAllowed(method): |
894
|
|
|
# WST's method does not supports this analysis |
895
|
|
|
continue |
896
|
|
|
|
897
|
|
|
# Get the slot where analyses from this sample are located |
898
|
|
|
sample_uid = analysis.getRequestUID() |
899
|
|
|
slot = samples_slots.get(sample_uid) |
900
|
|
|
if not slot: |
901
|
|
|
if len(available_slots) == 0: |
902
|
|
|
# Maybe next analysis is from a sample with a slot assigned |
903
|
|
|
continue |
904
|
|
|
|
905
|
|
|
# Pop next available slot |
906
|
|
|
slot = available_slots.pop() |
907
|
|
|
|
908
|
|
|
# Feed the samples_slots |
909
|
|
|
samples_slots[sample_uid] = slot |
910
|
|
|
new_sample_uids.append(sample_uid) |
911
|
|
|
|
912
|
|
|
# Keep track of the analyses to add |
913
|
|
|
new_analyses.append((analysis, sample_uid)) |
914
|
|
|
|
915
|
|
|
# Re-sort slots for new samples to display them in natural order |
916
|
|
|
new_slots = map(lambda s: samples_slots.get(s), new_sample_uids) |
917
|
|
|
sorted_slots = zip(sorted(new_sample_uids), sorted(new_slots)) |
918
|
|
|
for sample_id, slot in sorted_slots: |
919
|
|
|
samples_slots[sample_uid] = slot |
920
|
|
|
|
921
|
|
|
# Add analyses to the worksheet |
922
|
|
|
for analysis, sample_uid in new_analyses: |
923
|
|
|
slot = samples_slots[sample_uid] |
924
|
|
|
self.addAnalysis(analysis, slot) |
925
|
|
|
|
926
|
|
|
def _apply_worksheet_template_duplicate_analyses(self, wst): |
927
|
|
|
"""Add duplicate analyses to worksheet according to the worksheet template |
928
|
|
|
layout passed in w/o overwrite slots that are already filled. |
929
|
|
|
|
930
|
|
|
If the slot where the duplicate must be located is available, but the |
931
|
|
|
slot where the routine analysis should be found is empty, no duplicate |
932
|
|
|
will be generated for that given slot. |
933
|
|
|
|
934
|
|
|
:param wst: worksheet template used as the layout |
935
|
|
|
:returns: None |
936
|
|
|
""" |
937
|
|
|
wst_layout = wst.getTemplateLayout() |
938
|
|
|
|
939
|
|
|
for row in wst_layout: |
940
|
|
|
if row['type'] != 'd': |
941
|
|
|
continue |
942
|
|
|
|
943
|
|
|
src_pos = to_int(row['dup']) |
944
|
|
|
dest_pos = to_int(row['pos']) |
945
|
|
|
|
946
|
|
|
self.addDuplicateAnalyses(src_pos, dest_pos) |
947
|
|
|
|
948
|
|
|
def _resolve_reference_sample(self, reference_samples=None, |
949
|
|
|
service_uids=None): |
950
|
|
|
"""Returns the reference sample from reference_samples passed in that fits |
951
|
|
|
better with the service uid requirements. This is, the reference sample |
952
|
|
|
that covers most (or all) of the service uids passed in and has less |
953
|
|
|
number of remaining service_uids. |
954
|
|
|
|
955
|
|
|
If no reference_samples are set, returns None |
956
|
|
|
|
957
|
|
|
If no service_uids are set, returns the first reference_sample |
958
|
|
|
|
959
|
|
|
:param reference_samples: list of reference samples |
960
|
|
|
:param service_uids: list of service uids |
961
|
|
|
:return: the reference sample that fits better with the service uids |
962
|
|
|
""" |
963
|
|
|
if not reference_samples: |
964
|
|
|
return None, list() |
965
|
|
|
|
966
|
|
|
if not service_uids: |
967
|
|
|
# Since no service filtering has been defined, there is no need to |
968
|
|
|
# look for the best choice. Return the first one |
969
|
|
|
sample = reference_samples[0] |
970
|
|
|
spec_uids = sample.getSupportedServices(only_uids=True) |
971
|
|
|
return sample, spec_uids |
972
|
|
|
|
973
|
|
|
best_score = [0, 0] |
974
|
|
|
best_sample = None |
975
|
|
|
best_supported = None |
976
|
|
|
for sample in reference_samples: |
977
|
|
|
specs_uids = sample.getSupportedServices(only_uids=True) |
978
|
|
|
supported = [uid for uid in specs_uids if uid in service_uids] |
979
|
|
|
matches = len(supported) |
980
|
|
|
overlays = len(service_uids) - matches |
981
|
|
|
overlays = 0 if overlays < 0 else overlays |
982
|
|
|
|
983
|
|
|
if overlays == 0 and matches == len(service_uids): |
984
|
|
|
# Perfect match.. no need to go further |
985
|
|
|
return sample, supported |
986
|
|
|
|
987
|
|
|
if not best_sample \ |
988
|
|
|
or matches > best_score[0] \ |
989
|
|
|
or (matches == best_score[0] and overlays < best_score[1]): |
990
|
|
|
best_sample = sample |
991
|
|
|
best_score = [matches, overlays] |
992
|
|
|
best_supported = supported |
993
|
|
|
|
994
|
|
|
return best_sample, best_supported |
995
|
|
|
|
996
|
|
|
def _resolve_reference_samples(self, wst, type): |
997
|
|
|
""" |
998
|
|
|
Resolves the slots and reference samples in accordance with the |
999
|
|
|
Worksheet Template passed in and the type passed in. |
1000
|
|
|
Returns a list of dictionaries |
1001
|
|
|
:param wst: Worksheet Template that defines the layout |
1002
|
|
|
:param type: type of analyses ('b' for blanks, 'c' for controls) |
1003
|
|
|
:return: list of dictionaries |
1004
|
|
|
""" |
1005
|
|
|
if not type or type not in ['b', 'c']: |
1006
|
|
|
return [] |
1007
|
|
|
|
1008
|
|
|
bc = api.get_tool("senaite_catalog") |
1009
|
|
|
wst_type = type == 'b' and 'blank_ref' or 'control_ref' |
1010
|
|
|
|
1011
|
|
|
slots_sample = list() |
1012
|
|
|
available_slots = self.resolve_available_slots(wst, type) |
1013
|
|
|
wst_layout = wst.getTemplateLayout() |
1014
|
|
|
for row in wst_layout: |
1015
|
|
|
slot = int(row['pos']) |
1016
|
|
|
if slot not in available_slots: |
1017
|
|
|
continue |
1018
|
|
|
|
1019
|
|
|
ref_definition_uid = row.get(wst_type, None) |
1020
|
|
|
if not ref_definition_uid: |
1021
|
|
|
# Only reference analyses with reference definition can be used |
1022
|
|
|
# in worksheet templates |
1023
|
|
|
continue |
1024
|
|
|
|
1025
|
|
|
samples = bc(portal_type='ReferenceSample', |
1026
|
|
|
review_state='current', |
1027
|
|
|
is_active=True, |
1028
|
|
|
getReferenceDefinitionUID=ref_definition_uid) |
1029
|
|
|
|
1030
|
|
|
# We only want the reference samples that fit better with the type |
1031
|
|
|
# and with the analyses defined in the Template |
1032
|
|
|
services = wst.getServices() |
1033
|
|
|
services = [s.UID() for s in services] |
1034
|
|
|
candidates = list() |
1035
|
|
|
for sample in samples: |
1036
|
|
|
obj = api.get_object(sample) |
1037
|
|
|
if (type == 'b' and obj.getBlank()) or \ |
1038
|
|
|
(type == 'c' and not obj.getBlank()): |
1039
|
|
|
candidates.append(obj) |
1040
|
|
|
|
1041
|
|
|
sample, uids = self._resolve_reference_sample(candidates, services) |
1042
|
|
|
if not sample: |
1043
|
|
|
continue |
1044
|
|
|
|
1045
|
|
|
slots_sample.append({'slot': slot, |
1046
|
|
|
'sample': sample, |
1047
|
|
|
'supported_services': uids}) |
1048
|
|
|
|
1049
|
|
|
return slots_sample |
1050
|
|
|
|
1051
|
|
|
def _apply_worksheet_template_reference_analyses(self, wst, type='all'): |
1052
|
|
|
""" |
1053
|
|
|
Add reference analyses to worksheet according to the worksheet template |
1054
|
|
|
layout passed in. Does not overwrite slots that are already filled. |
1055
|
|
|
:param wst: worksheet template used as the layout |
1056
|
|
|
""" |
1057
|
|
|
if type == 'all': |
1058
|
|
|
self._apply_worksheet_template_reference_analyses(wst, 'b') |
1059
|
|
|
self._apply_worksheet_template_reference_analyses(wst, 'c') |
1060
|
|
|
return |
1061
|
|
|
|
1062
|
|
|
if type not in ['b', 'c']: |
1063
|
|
|
return |
1064
|
|
|
|
1065
|
|
|
references = self._resolve_reference_samples(wst, type) |
1066
|
|
|
for reference in references: |
1067
|
|
|
slot = reference['slot'] |
1068
|
|
|
sample = reference['sample'] |
1069
|
|
|
services = reference['supported_services'] |
1070
|
|
|
self.addReferenceAnalyses(sample, services, slot) |
1071
|
|
|
|
1072
|
|
|
def applyWorksheetTemplate(self, wst, analyses=None): |
1073
|
|
|
""" Add analyses to worksheet according to wst's layout. |
1074
|
|
|
Will not overwrite slots which are filled already. |
1075
|
|
|
If the selected template has an instrument assigned, it will |
1076
|
|
|
only be applied to those analyses for which the instrument |
1077
|
|
|
is allowed, the same happens with methods. |
1078
|
|
|
|
1079
|
|
|
:param wst: worksheet template used as the layout |
1080
|
|
|
""" |
1081
|
|
|
wst = api.get_object(wst, default=None) |
1082
|
|
|
|
1083
|
|
|
# Store the Worksheet Template field and reindex it |
1084
|
|
|
self.getField("WorksheetTemplate").set(self, wst) |
1085
|
|
|
self.reindexObject(idxs=["getWorksheetTemplateTitle"]) |
1086
|
|
|
|
1087
|
|
|
if not wst: |
1088
|
|
|
return |
1089
|
|
|
|
1090
|
|
|
# Apply the template for routine analyses |
1091
|
|
|
self._apply_worksheet_template_routine_analyses(wst, analyses=analyses) |
1092
|
|
|
|
1093
|
|
|
# Apply the template for duplicate analyses |
1094
|
|
|
self._apply_worksheet_template_duplicate_analyses(wst) |
1095
|
|
|
|
1096
|
|
|
# Apply the template for reference analyses (blanks and controls) |
1097
|
|
|
self._apply_worksheet_template_reference_analyses(wst) |
1098
|
|
|
|
1099
|
|
|
# Assign the instrument |
1100
|
|
|
instrument = wst.getInstrument() |
1101
|
|
|
if instrument: |
1102
|
|
|
self.setInstrument(instrument, True) |
1103
|
|
|
|
1104
|
|
|
# Assign the method |
1105
|
|
|
method = wst.getRestrictToMethod() |
1106
|
|
|
if method: |
1107
|
|
|
self.setMethod(method, True) |
1108
|
|
|
|
1109
|
|
|
def getWorksheetTemplateUID(self): |
1110
|
|
|
""" |
1111
|
|
|
Returns the template's UID assigned to this worksheet |
1112
|
|
|
:returns: worksheet's UID |
1113
|
|
|
:rtype: UID as string |
1114
|
|
|
""" |
1115
|
|
|
return self.getRawWorksheetTemplate() |
1116
|
|
|
|
1117
|
|
|
def getWorksheetTemplateTitle(self): |
1118
|
|
|
""" |
1119
|
|
|
Returns the template's Title assigned to this worksheet |
1120
|
|
|
:returns: worksheet's Title |
1121
|
|
|
:rtype: string |
1122
|
|
|
""" |
1123
|
|
|
ws = self.getWorksheetTemplate() |
1124
|
|
|
if ws: |
1125
|
|
|
return ws.Title() |
1126
|
|
|
return '' |
1127
|
|
|
|
1128
|
|
|
def getWorksheetTemplateURL(self): |
1129
|
|
|
""" |
1130
|
|
|
Returns the template's URL assigned to this worksheet |
1131
|
|
|
:returns: worksheet's URL |
1132
|
|
|
:rtype: string |
1133
|
|
|
""" |
1134
|
|
|
ws = self.getWorksheetTemplate() |
1135
|
|
|
if ws: |
1136
|
|
|
return ws.absolute_url_path() |
1137
|
|
|
return '' |
1138
|
|
|
|
1139
|
|
|
def getQCAnalyses(self): |
1140
|
|
|
""" |
1141
|
|
|
Return the Quality Control analyses. |
1142
|
|
|
:returns: a list of QC analyses |
1143
|
|
|
:rtype: List of ReferenceAnalysis/DuplicateAnalysis |
1144
|
|
|
""" |
1145
|
|
|
qc_types = ['ReferenceAnalysis', 'DuplicateAnalysis'] |
1146
|
|
|
analyses = self.getAnalyses() |
1147
|
|
|
return [a for a in analyses if a.portal_type in qc_types] |
1148
|
|
|
|
1149
|
|
|
def getDuplicateAnalyses(self): |
1150
|
|
|
"""Return the duplicate analyses assigned to the current worksheet |
1151
|
|
|
:return: List of DuplicateAnalysis |
1152
|
|
|
:rtype: List of IDuplicateAnalysis objects""" |
1153
|
|
|
ans = self.getAnalyses() |
1154
|
|
|
duplicates = [an for an in ans if IDuplicateAnalysis.providedBy(an)] |
1155
|
|
|
return duplicates |
1156
|
|
|
|
1157
|
|
|
def getReferenceAnalyses(self): |
1158
|
|
|
"""Return the reference analyses (controls) assigned to the current |
1159
|
|
|
worksheet |
1160
|
|
|
:return: List of reference analyses |
1161
|
|
|
:rtype: List of IReferenceAnalysis objects""" |
1162
|
|
|
ans = self.getAnalyses() |
1163
|
|
|
references = [an for an in ans if IReferenceAnalysis.providedBy(an)] |
1164
|
|
|
return references |
1165
|
|
|
|
1166
|
|
|
def getRegularAnalyses(self): |
1167
|
|
|
""" |
1168
|
|
|
Return the analyses assigned to the current worksheet that are directly |
1169
|
|
|
associated to an Analysis Request but are not QC analyses. This is all |
1170
|
|
|
analyses that implement IRoutineAnalysis |
1171
|
|
|
:return: List of regular analyses |
1172
|
|
|
:rtype: List of ReferenceAnalysis/DuplicateAnalysis |
1173
|
|
|
""" |
1174
|
|
|
qc_types = ['ReferenceAnalysis', 'DuplicateAnalysis'] |
1175
|
|
|
analyses = self.getAnalyses() |
1176
|
|
|
return [a for a in analyses if a.portal_type not in qc_types] |
1177
|
|
|
|
1178
|
|
|
def getNumberOfQCAnalyses(self): |
1179
|
|
|
""" |
1180
|
|
|
Returns the number of Quality Control analyses. |
1181
|
|
|
:returns: number of QC analyses |
1182
|
|
|
:rtype: integer |
1183
|
|
|
""" |
1184
|
|
|
return len(self.getQCAnalyses()) |
1185
|
|
|
|
1186
|
|
|
def getNumberOfRegularAnalyses(self): |
1187
|
|
|
""" |
1188
|
|
|
Returns the number of Regular analyses. |
1189
|
|
|
:returns: number of analyses |
1190
|
|
|
:rtype: integer |
1191
|
|
|
""" |
1192
|
|
|
return len(self.getRegularAnalyses()) |
1193
|
|
|
|
1194
|
|
|
def getNumberOfQCSamples(self): |
1195
|
|
|
""" |
1196
|
|
|
Returns the number of Quality Control samples. |
1197
|
|
|
:returns: number of QC samples |
1198
|
|
|
:rtype: integer |
1199
|
|
|
""" |
1200
|
|
|
qc_analyses = self.getQCAnalyses() |
1201
|
|
|
qc_samples = [a.getSample().UID() for a in qc_analyses] |
1202
|
|
|
# discarding any duplicate values |
1203
|
|
|
return len(set(qc_samples)) |
1204
|
|
|
|
1205
|
|
|
def getNumberOfRegularSamples(self): |
1206
|
|
|
""" |
1207
|
|
|
Returns the number of regular samples. |
1208
|
|
|
:returns: number of regular samples |
1209
|
|
|
:rtype: integer |
1210
|
|
|
""" |
1211
|
|
|
analyses = self.getRegularAnalyses() |
1212
|
|
|
samples = [a.getRequestUID() for a in analyses] |
1213
|
|
|
# discarding any duplicate values |
1214
|
|
|
return len(set(samples)) |
1215
|
|
|
|
1216
|
|
|
def setInstrument(self, instrument, override_analyses=False): |
1217
|
|
|
"""Assigns the specified analytical instrument to the analyses in this |
1218
|
|
|
worksheet that are compatible with the instrument. The system will |
1219
|
|
|
attempt to assign the first method supported by the instrument that is |
1220
|
|
|
also compatible with each analysis. |
1221
|
|
|
|
1222
|
|
|
By default, the instrument and method assigned to the analysis won't be |
1223
|
|
|
replaced unless the analysis does not have an instrument assigned yet |
1224
|
|
|
or the parameter override_analyses is set to True. Analyses that are |
1225
|
|
|
incompatible with the specified instrument will remain unchanged. |
1226
|
|
|
""" |
1227
|
|
|
analyses = self.getAnalyses() |
1228
|
|
|
instrument = api.get_object(instrument, default=None) |
1229
|
|
|
|
1230
|
|
|
# find out the methods supported by the instrument, if any |
1231
|
|
|
supported_methods = instrument.getRawMethods() if instrument else [] |
1232
|
|
|
|
1233
|
|
|
total = 0 |
1234
|
|
|
for an in analyses: |
1235
|
|
|
|
1236
|
|
|
if not override_analyses and an.getRawInstrument(): |
1237
|
|
|
# skip, no overwrite analysis if an instrument is set |
1238
|
|
|
continue |
1239
|
|
|
|
1240
|
|
|
if not an.isInstrumentAllowed(instrument): |
1241
|
|
|
# skip, instrument cannot run this analysis |
1242
|
|
|
continue |
1243
|
|
|
|
1244
|
|
|
# assign the instrument |
1245
|
|
|
an.setInstrument(instrument) |
1246
|
|
|
total += 1 |
1247
|
|
|
|
1248
|
|
|
if an.getRawMethod() in supported_methods: |
1249
|
|
|
# the analysis method is supported by this instrument |
1250
|
|
|
continue |
1251
|
|
|
|
1252
|
|
|
# reset and try to assign the first supported method |
1253
|
|
|
allowed = an.getRawAllowedMethods() |
1254
|
|
|
methods = list(filter(lambda m: m in allowed, supported_methods)) |
|
|
|
|
1255
|
|
|
method = methods[0] if methods else None |
1256
|
|
|
an.setMethod(method) |
1257
|
|
|
|
1258
|
|
|
self.getField('Instrument').set(self, instrument) |
1259
|
|
|
return total |
1260
|
|
|
|
1261
|
|
|
def setMethod(self, method, override_analyses=False): |
1262
|
|
|
""" Sets the specified method to the Analyses from the |
1263
|
|
|
Worksheet. Only sets the method if the Analysis |
1264
|
|
|
allows to keep the integrity. |
1265
|
|
|
If an analysis has already been assigned to a method, it won't |
1266
|
|
|
be overriden. |
1267
|
|
|
Returns the number of analyses affected. |
1268
|
|
|
""" |
1269
|
|
|
analyses = [an for an in self.getAnalyses() |
1270
|
|
|
if (not an.getMethod() or |
1271
|
|
|
not an.getInstrument() or |
1272
|
|
|
override_analyses) and an.isMethodAllowed(method)] |
1273
|
|
|
total = 0 |
1274
|
|
|
for an in analyses: |
1275
|
|
|
success = False |
1276
|
|
|
if an.isMethodAllowed(method): |
1277
|
|
|
success = an.setMethod(method) |
1278
|
|
|
if success is True: |
1279
|
|
|
total += 1 |
1280
|
|
|
|
1281
|
|
|
self.getField('Method').set(self, method) |
1282
|
|
|
return total |
1283
|
|
|
|
1284
|
|
|
def getAnalystName(self): |
1285
|
|
|
""" Returns the name of the currently assigned analyst |
1286
|
|
|
""" |
1287
|
|
|
mtool = getToolByName(self, 'portal_membership') |
1288
|
|
|
analyst = self.getAnalyst().strip() |
1289
|
|
|
analyst_member = mtool.getMemberById(analyst) |
1290
|
|
|
if analyst_member is not None: |
1291
|
|
|
return analyst_member.getProperty('fullname') |
1292
|
|
|
return analyst |
1293
|
|
|
|
1294
|
|
|
# TODO Workflow - Worksheet - Move to workflow.worksheet.events |
1295
|
|
|
def workflow_script_reject(self): |
1296
|
|
|
"""Copy real analyses to RejectAnalysis, with link to real |
1297
|
|
|
create a new worksheet, with the original analyses, and new |
1298
|
|
|
duplicates and references to match the rejected |
1299
|
|
|
worksheet. |
1300
|
|
|
""" |
1301
|
|
|
if skip(self, "reject"): |
1302
|
|
|
return |
1303
|
|
|
workflow = self.portal_workflow |
1304
|
|
|
|
1305
|
|
|
def copy_src_fields_to_dst(src, dst): |
1306
|
|
|
# These will be ignored when copying field values between analyses |
1307
|
|
|
ignore_fields = [ |
1308
|
|
|
'UID', |
1309
|
|
|
'id', |
1310
|
|
|
'title', |
1311
|
|
|
'allowDiscussion', |
1312
|
|
|
'subject', |
1313
|
|
|
'description', |
1314
|
|
|
'location', |
1315
|
|
|
'contributors', |
1316
|
|
|
'creators', |
1317
|
|
|
'effectiveDate', |
1318
|
|
|
'expirationDate', |
1319
|
|
|
'language', |
1320
|
|
|
'rights', |
1321
|
|
|
'creation_date', |
1322
|
|
|
'modification_date', |
1323
|
|
|
'Layout', # ws |
1324
|
|
|
'Analyses', # ws |
1325
|
|
|
] |
1326
|
|
|
fields = src.Schema().fields() |
1327
|
|
|
for field in fields: |
1328
|
|
|
fieldname = field.getName() |
1329
|
|
|
if fieldname in ignore_fields: |
1330
|
|
|
continue |
1331
|
|
|
getter = getattr(src, 'get' + fieldname, |
1332
|
|
|
src.Schema().getField(fieldname).getAccessor(src)) |
1333
|
|
|
setter = getattr(dst, 'set' + fieldname, |
1334
|
|
|
dst.Schema().getField(fieldname).getMutator(dst)) |
1335
|
|
|
if getter is None or setter is None: |
1336
|
|
|
# ComputedField |
1337
|
|
|
continue |
1338
|
|
|
setter(getter()) |
1339
|
|
|
|
1340
|
|
|
analysis_positions = {} |
1341
|
|
|
for item in self.getLayout(): |
1342
|
|
|
analysis_positions[item['analysis_uid']] = item['position'] |
1343
|
|
|
old_layout = [] |
1344
|
|
|
new_layout = [] |
1345
|
|
|
|
1346
|
|
|
# New worksheet |
1347
|
|
|
worksheets = self.aq_parent |
1348
|
|
|
new_ws = _createObjectByType('Worksheet', worksheets, tmpID()) |
1349
|
|
|
new_ws.unmarkCreationFlag() |
1350
|
|
|
new_ws_id = renameAfterCreation(new_ws) |
1351
|
|
|
copy_src_fields_to_dst(self, new_ws) |
1352
|
|
|
new_ws.edit( |
1353
|
|
|
Number=new_ws_id, |
1354
|
|
|
Remarks=self.getRemarks() |
1355
|
|
|
) |
1356
|
|
|
|
1357
|
|
|
# Objects are being created inside other contexts, but we want their |
1358
|
|
|
# workflow handlers to be aware of which worksheet this is occurring in. |
1359
|
|
|
# We save the worksheet in request['context_uid']. |
1360
|
|
|
# We reset it again below.... be very sure that this is set to the |
1361
|
|
|
# UID of the containing worksheet before invoking any transitions on |
1362
|
|
|
# analyses. |
1363
|
|
|
self.REQUEST['context_uid'] = new_ws.UID() |
1364
|
|
|
|
1365
|
|
|
# loop all analyses |
1366
|
|
|
analyses = self.getAnalyses() |
1367
|
|
|
new_ws_analyses = [] |
1368
|
|
|
old_ws_analyses = [] |
1369
|
|
|
for analysis in analyses: |
1370
|
|
|
# Skip published or verified analyses |
1371
|
|
|
review_state = workflow.getInfoFor(analysis, 'review_state', '') |
1372
|
|
|
if review_state in ['published', 'verified', 'retracted']: |
1373
|
|
|
old_ws_analyses.append(analysis.UID()) |
1374
|
|
|
old_layout.append({'type': 'a', |
1375
|
|
|
'analysis_uid': analysis.UID(), |
1376
|
|
|
'container_uid': analysis.aq_parent.UID()}) |
1377
|
|
|
continue |
1378
|
|
|
# Normal analyses: |
1379
|
|
|
# - Create matching RejectAnalysis inside old WS |
1380
|
|
|
# - Link analysis to new WS in same position |
1381
|
|
|
# - Copy all field values |
1382
|
|
|
# - Clear analysis result, and set Retested flag |
1383
|
|
|
if analysis.portal_type == 'Analysis': |
1384
|
|
|
reject = _createObjectByType('RejectAnalysis', self, tmpID()) |
1385
|
|
|
reject.unmarkCreationFlag() |
1386
|
|
|
copy_src_fields_to_dst(analysis, reject) |
1387
|
|
|
reject.setAnalysis(analysis) |
1388
|
|
|
reject.reindexObject() |
1389
|
|
|
analysis.edit( |
1390
|
|
|
Result=None, |
1391
|
|
|
Retested=True, |
1392
|
|
|
) |
1393
|
|
|
analysis.reindexObject() |
1394
|
|
|
position = analysis_positions[analysis.UID()] |
1395
|
|
|
old_ws_analyses.append(reject.UID()) |
1396
|
|
|
old_layout.append({'position': position, |
1397
|
|
|
'type': 'r', |
1398
|
|
|
'analysis_uid': reject.UID(), |
1399
|
|
|
'container_uid': self.UID()}) |
1400
|
|
|
new_ws_analyses.append(analysis.UID()) |
1401
|
|
|
new_layout.append({'position': position, |
1402
|
|
|
'type': 'a', |
1403
|
|
|
'analysis_uid': analysis.UID(), |
1404
|
|
|
'container_uid': analysis.aq_parent.UID()}) |
1405
|
|
|
# Reference analyses |
1406
|
|
|
# - Create a new reference analysis in the new worksheet |
1407
|
|
|
# - Transition the original analysis to 'rejected' state |
1408
|
|
|
if analysis.portal_type == 'ReferenceAnalysis': |
1409
|
|
|
service_uid = analysis.getServiceUID() |
1410
|
|
|
reference = analysis.aq_parent |
1411
|
|
|
new_reference = create_reference_analysis(reference, service_uid) |
1412
|
|
|
reference_type = new_reference.getReferenceType() |
1413
|
|
|
new_analysis_uid = api.get_uid(new_reference) |
1414
|
|
|
position = analysis_positions[analysis.UID()] |
1415
|
|
|
old_ws_analyses.append(analysis.UID()) |
1416
|
|
|
old_layout.append({'position': position, |
1417
|
|
|
'type': reference_type, |
1418
|
|
|
'analysis_uid': analysis.UID(), |
1419
|
|
|
'container_uid': reference.UID()}) |
1420
|
|
|
new_ws_analyses.append(new_analysis_uid) |
1421
|
|
|
new_layout.append({'position': position, |
1422
|
|
|
'type': reference_type, |
1423
|
|
|
'analysis_uid': new_analysis_uid, |
1424
|
|
|
'container_uid': reference.UID()}) |
1425
|
|
|
workflow.doActionFor(analysis, 'reject') |
1426
|
|
|
analysis.reindexObject() |
1427
|
|
|
# Duplicate analyses |
1428
|
|
|
# - Create a new duplicate inside the new worksheet |
1429
|
|
|
# - Transition the original analysis to 'rejected' state |
1430
|
|
|
if analysis.portal_type == 'DuplicateAnalysis': |
1431
|
|
|
duplicate_id = new_ws.generateUniqueId('DuplicateAnalysis') |
1432
|
|
|
new_duplicate = _createObjectByType('DuplicateAnalysis', |
1433
|
|
|
new_ws, duplicate_id) |
1434
|
|
|
new_duplicate.unmarkCreationFlag() |
1435
|
|
|
copy_src_fields_to_dst(analysis, new_duplicate) |
1436
|
|
|
new_duplicate.reindexObject() |
1437
|
|
|
position = analysis_positions[analysis.UID()] |
1438
|
|
|
old_ws_analyses.append(analysis.UID()) |
1439
|
|
|
old_layout.append({'position': position, |
1440
|
|
|
'type': 'd', |
1441
|
|
|
'analysis_uid': analysis.UID(), |
1442
|
|
|
'container_uid': self.UID()}) |
1443
|
|
|
new_ws_analyses.append(new_duplicate.UID()) |
1444
|
|
|
new_layout.append({'position': position, |
1445
|
|
|
'type': 'd', |
1446
|
|
|
'analysis_uid': new_duplicate.UID(), |
1447
|
|
|
'container_uid': new_ws.UID()}) |
1448
|
|
|
workflow.doActionFor(analysis, 'reject') |
1449
|
|
|
analysis.reindexObject() |
1450
|
|
|
|
1451
|
|
|
new_ws.setAnalyses(new_ws_analyses) |
1452
|
|
|
new_ws.setLayout(new_layout) |
1453
|
|
|
new_ws.replaces_rejected_worksheet = self.UID() |
1454
|
|
|
for analysis in new_ws.getAnalyses(): |
1455
|
|
|
review_state = workflow.getInfoFor(analysis, 'review_state', '') |
1456
|
|
|
if review_state == 'to_be_verified': |
1457
|
|
|
# TODO Workflow - Analysis Retest transition within a Worksheet |
1458
|
|
|
changeWorkflowState(analysis, ANALYSIS_WORKFLOW, "assigned") |
1459
|
|
|
self.REQUEST['context_uid'] = self.UID() |
1460
|
|
|
self.setLayout(old_layout) |
1461
|
|
|
self.setAnalyses(old_ws_analyses) |
1462
|
|
|
self.replaced_by = new_ws.UID() |
1463
|
|
|
|
1464
|
|
|
# TODO Workflow - Worksheet - Remove this function |
1465
|
|
|
def checkUserManage(self): |
1466
|
|
|
""" Checks if the current user has granted access to this worksheet |
1467
|
|
|
and if has also privileges for managing it. |
1468
|
|
|
""" |
1469
|
|
|
granted = False |
1470
|
|
|
can_access = self.checkUserAccess() |
1471
|
|
|
|
1472
|
|
|
if can_access is True: |
1473
|
|
|
pm = getToolByName(self, 'portal_membership') |
1474
|
|
|
if can_edit_worksheet(self): |
1475
|
|
|
# Check if the current user is the WS's current analyst |
1476
|
|
|
member = pm.getAuthenticatedMember() |
1477
|
|
|
analyst = self.getAnalyst().strip() |
1478
|
|
|
if analyst != _c(member.getId()): |
1479
|
|
|
# Has management privileges? |
1480
|
|
|
if can_manage_worksheets(self): |
1481
|
|
|
granted = True |
1482
|
|
|
else: |
1483
|
|
|
granted = True |
1484
|
|
|
|
1485
|
|
|
return granted |
1486
|
|
|
|
1487
|
|
|
# TODO Workflow - Worksheet - Remove this function |
1488
|
|
|
def checkUserAccess(self): |
1489
|
|
|
""" Checks if the current user has granted access to this worksheet. |
1490
|
|
|
Returns False if the user has no access, otherwise returns True |
1491
|
|
|
""" |
1492
|
|
|
# Deny access to foreign analysts |
1493
|
|
|
allowed = True |
1494
|
|
|
pm = getToolByName(self, "portal_membership") |
1495
|
|
|
member = pm.getAuthenticatedMember() |
1496
|
|
|
|
1497
|
|
|
analyst = self.getAnalyst().strip() |
1498
|
|
|
if analyst != _c(member.getId()): |
1499
|
|
|
roles = member.getRoles() |
1500
|
|
|
restrict = 'Manager' not in roles \ |
1501
|
|
|
and 'LabManager' not in roles \ |
1502
|
|
|
and 'LabClerk' not in roles \ |
1503
|
|
|
and 'RegulatoryInspector' not in roles \ |
1504
|
|
|
and self.bika_setup.getRestrictWorksheetUsersAccess() |
1505
|
|
|
allowed = not restrict |
1506
|
|
|
|
1507
|
|
|
return allowed |
1508
|
|
|
|
1509
|
|
|
def setAnalyst(self, analyst): |
1510
|
|
|
for analysis in self.getAnalyses(): |
1511
|
|
|
analysis.setAnalyst(analyst) |
1512
|
|
|
self.Schema().getField('Analyst').set(self, analyst) |
1513
|
|
|
self.reindexObject() |
1514
|
|
|
|
1515
|
|
|
def getAnalysesUIDs(self): |
1516
|
|
|
""" |
1517
|
|
|
Returns the analyses UIDs from the analyses assigned to this worksheet |
1518
|
|
|
:returns: a list of UIDs |
1519
|
|
|
:rtype: a list of strings |
1520
|
|
|
""" |
1521
|
|
|
analyses = self.getAnalyses() |
1522
|
|
|
if isinstance(analyses, list): |
1523
|
|
|
return [an.UID() for an in analyses] |
1524
|
|
|
return [] |
1525
|
|
|
|
1526
|
|
|
def getProgressPercentage(self): |
1527
|
|
|
"""Returns the progress percentage of this worksheet |
1528
|
|
|
""" |
1529
|
|
|
state = api.get_workflow_status_of(self) |
1530
|
|
|
if state == "verified": |
1531
|
|
|
return 100 |
1532
|
|
|
|
1533
|
|
|
steps = 0 |
1534
|
|
|
query = dict(getWorksheetUID=api.get_uid(self)) |
1535
|
|
|
analyses = api.search(query, ANALYSIS_CATALOG) |
1536
|
|
|
max_steps = len(analyses) * 2 |
1537
|
|
|
for analysis in analyses: |
1538
|
|
|
an_state = analysis.review_state |
1539
|
|
|
if an_state in ["rejected", "retracted", "cancelled"]: |
1540
|
|
|
steps += 2 |
1541
|
|
|
elif an_state in ["verified", "published"]: |
1542
|
|
|
steps += 2 |
1543
|
|
|
elif an_state == "to_be_verified": |
1544
|
|
|
steps += 1 |
1545
|
|
|
if steps == 0: |
1546
|
|
|
return 0 |
1547
|
|
|
if steps > max_steps: |
1548
|
|
|
return 100 |
1549
|
|
|
return (steps * 100)/max_steps |
1550
|
|
|
|
1551
|
|
|
registerType(Worksheet, PROJECTNAME) |
1552
|
|
|
|