Passed
Push — 2.x ( 5d5eda...426827 )
by Ramon
08:21
created

Worksheet.setInstrument()   C

Complexity

Conditions 9

Size

Total Lines 44
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 44
rs 6.6666
c 0
b 0
f 0
cc 9
nop 3
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-2024 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
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable schema does not seem to be defined.
Loading history...
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,
0 ignored issues
show
introduced by
The variable uid does not seem to be defined for all execution paths.
Loading history...
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):
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
        :returns: None
833
        """
834
        # Get the services from the Worksheet Template
835
        service_uids = wst.getRawServices()
836
        if not service_uids:
837
            # No service uids assigned to this Worksheet Template, skip
838
            logger.warn("Worksheet Template {} has no services assigned"
839
                        .format(api.get_path(wst)))
840
            return
841
842
        # Search for unassigned analyses
843
        query = {
844
            "portal_type": "Analysis",
845
            "getServiceUID": service_uids,
846
            "review_state": "unassigned",
847
            "sort_on": "getPrioritySortkey"
848
        }
849
        analyses = api.search(query, ANALYSIS_CATALOG)
850
        if not analyses:
851
            return
852
853
        # Available slots for routine analyses
854
        available_slots = self.resolve_available_slots(wst, 'a')
855
        available_slots.sort(reverse=True)
856
857
        # If there is an instrument assigned to this Worksheet Template, take
858
        # only the analyses that allow this instrument into consideration.
859
        instrument = wst.getRawInstrument()
860
861
        # If there is method assigned to the Worksheet Template, take only the
862
        # analyses that allow this method into consideration.
863
        method = wst.getRawRestrictToMethod()
864
865
        # Map existing sample uids with slots
866
        samples_slots = dict(self.get_containers_slots())
867
        new_sample_uids = []
868
        new_analyses = []
869
870
        for analysis in analyses:
871
            analysis = api.get_object(analysis)
872
873
            if instrument and not analysis.isInstrumentAllowed(instrument):
874
                # WST's Instrument does not supports this analysis
875
                continue
876
877
            if method and not analysis.isMethodAllowed(method):
878
                # WST's method does not supports this analysis
879
                continue
880
881
            # Get the slot where analyses from this sample are located
882
            sample_uid = analysis.getRequestUID()
883
            slot = samples_slots.get(sample_uid)
884
            if not slot:
885
                if len(available_slots) == 0:
886
                    # Maybe next analysis is from a sample with a slot assigned
887
                    continue
888
889
                # Pop next available slot
890
                slot = available_slots.pop()
891
892
                # Feed the samples_slots
893
                samples_slots[sample_uid] = slot
894
                new_sample_uids.append(sample_uid)
895
896
            # Keep track of the analyses to add
897
            new_analyses.append((analysis, sample_uid))
898
899
        # Re-sort slots for new samples to display them in natural order
900
        new_slots = map(lambda s: samples_slots.get(s), new_sample_uids)
901
        sorted_slots = zip(sorted(new_sample_uids), sorted(new_slots))
902
        for sample_id, slot in sorted_slots:
903
            samples_slots[sample_uid] = slot
904
905
        # Add analyses to the worksheet
906
        for analysis, sample_uid in new_analyses:
907
            slot = samples_slots[sample_uid]
908
            self.addAnalysis(analysis, slot)
909
910
    def _apply_worksheet_template_duplicate_analyses(self, wst):
911
        """Add duplicate analyses to worksheet according to the worksheet template
912
        layout passed in w/o overwrite slots that are already filled.
913
914
        If the slot where the duplicate must be located is available, but the
915
        slot where the routine analysis should be found is empty, no duplicate
916
        will be generated for that given slot.
917
918
        :param wst: worksheet template used as the layout
919
        :returns: None
920
        """
921
        wst_layout = wst.getTemplateLayout()
922
923
        for row in wst_layout:
924
            if row['type'] != 'd':
925
                continue
926
927
            src_pos = to_int(row['dup'])
928
            dest_pos = to_int(row['pos'])
929
930
            self.addDuplicateAnalyses(src_pos, dest_pos)
931
932
    def _resolve_reference_sample(self, reference_samples=None,
933
                                  service_uids=None):
934
        """Returns the reference sample from reference_samples passed in that fits
935
        better with the service uid requirements. This is, the reference sample
936
        that covers most (or all) of the service uids passed in and has less
937
        number of remaining service_uids.
938
939
        If no reference_samples are set, returns None
940
941
        If no service_uids are set, returns the first reference_sample
942
943
        :param reference_samples: list of reference samples
944
        :param service_uids: list of service uids
945
        :return: the reference sample that fits better with the service uids
946
        """
947
        if not reference_samples:
948
            return None, list()
949
950
        if not service_uids:
951
            # Since no service filtering has been defined, there is no need to
952
            # look for the best choice. Return the first one
953
            sample = reference_samples[0]
954
            spec_uids = sample.getSupportedServices(only_uids=True)
955
            return sample, spec_uids
956
957
        best_score = [0, 0]
958
        best_sample = None
959
        best_supported = None
960
        for sample in reference_samples:
961
            specs_uids = sample.getSupportedServices(only_uids=True)
962
            supported = [uid for uid in specs_uids if uid in service_uids]
963
            matches = len(supported)
964
            overlays = len(service_uids) - matches
965
            overlays = 0 if overlays < 0 else overlays
966
967
            if overlays == 0 and matches == len(service_uids):
968
                # Perfect match.. no need to go further
969
                return sample, supported
970
971
            if not best_sample \
972
                    or matches > best_score[0] \
973
                    or (matches == best_score[0] and overlays < best_score[1]):
974
                best_sample = sample
975
                best_score = [matches, overlays]
976
                best_supported = supported
977
978
        return best_sample, best_supported
979
980
    def _resolve_reference_samples(self, wst, type):
981
        """
982
        Resolves the slots and reference samples in accordance with the
983
        Worksheet Template passed in and the type passed in.
984
        Returns a list of dictionaries
985
        :param wst: Worksheet Template that defines the layout
986
        :param type: type of analyses ('b' for blanks, 'c' for controls)
987
        :return: list of dictionaries
988
        """
989
        if not type or type not in ['b', 'c']:
990
            return []
991
992
        bc = api.get_tool("senaite_catalog")
993
        wst_type = type == 'b' and 'blank_ref' or 'control_ref'
994
995
        slots_sample = list()
996
        available_slots = self.resolve_available_slots(wst, type)
997
        wst_layout = wst.getTemplateLayout()
998
        for row in wst_layout:
999
            slot = int(row['pos'])
1000
            if slot not in available_slots:
1001
                continue
1002
1003
            ref_definition_uid = row.get(wst_type, None)
1004
            if not ref_definition_uid:
1005
                # Only reference analyses with reference definition can be used
1006
                # in worksheet templates
1007
                continue
1008
1009
            samples = bc(portal_type='ReferenceSample',
1010
                         review_state='current',
1011
                         is_active=True,
1012
                         getReferenceDefinitionUID=ref_definition_uid)
1013
1014
            # We only want the reference samples that fit better with the type
1015
            # and with the analyses defined in the Template
1016
            services = wst.getServices()
1017
            services = [s.UID() for s in services]
1018
            candidates = list()
1019
            for sample in samples:
1020
                obj = api.get_object(sample)
1021
                if (type == 'b' and obj.getBlank()) or \
1022
                        (type == 'c' and not obj.getBlank()):
1023
                    candidates.append(obj)
1024
1025
            sample, uids = self._resolve_reference_sample(candidates, services)
1026
            if not sample:
1027
                continue
1028
1029
            slots_sample.append({'slot': slot,
1030
                                 'sample': sample,
1031
                                 'supported_services': uids})
1032
1033
        return slots_sample
1034
1035
    def _apply_worksheet_template_reference_analyses(self, wst, type='all'):
1036
        """
1037
        Add reference analyses to worksheet according to the worksheet template
1038
        layout passed in. Does not overwrite slots that are already filled.
1039
        :param wst: worksheet template used as the layout
1040
        """
1041
        if type == 'all':
1042
            self._apply_worksheet_template_reference_analyses(wst, 'b')
1043
            self._apply_worksheet_template_reference_analyses(wst, 'c')
1044
            return
1045
1046
        if type not in ['b', 'c']:
1047
            return
1048
1049
        references = self._resolve_reference_samples(wst, type)
1050
        for reference in references:
1051
            slot = reference['slot']
1052
            sample = reference['sample']
1053
            services = reference['supported_services']
1054
            self.addReferenceAnalyses(sample, services, slot)
1055
1056
    def applyWorksheetTemplate(self, wst):
1057
        """ Add analyses to worksheet according to wst's layout.
1058
            Will not overwrite slots which are filled already.
1059
            If the selected template has an instrument assigned, it will
1060
            only be applied to those analyses for which the instrument
1061
            is allowed, the same happens with methods.
1062
        """
1063
        # Store the Worksheet Template field and reindex it
1064
        self.getField('WorksheetTemplate').set(self, wst)
1065
        self.reindexObject(idxs=["getWorksheetTemplateTitle"])
1066
        
1067
        if not wst:
1068
            return
1069
1070
        # Apply the template for routine analyses
1071
        self._apply_worksheet_template_routine_analyses(wst)
1072
1073
        # Apply the template for duplicate analyses
1074
        self._apply_worksheet_template_duplicate_analyses(wst)
1075
1076
        # Apply the template for reference analyses (blanks and controls)
1077
        self._apply_worksheet_template_reference_analyses(wst)
1078
1079
        # Assign the instrument
1080
        instrument = wst.getInstrument()
1081
        if instrument:
1082
            self.setInstrument(instrument, True)
1083
1084
        # Assign the method
1085
        method = wst.getRestrictToMethod()
1086
        if method:
1087
            self.setMethod(method, True)
1088
1089
    def getWorksheetTemplateUID(self):
1090
        """
1091
        Returns the template's UID assigned to this worksheet
1092
        :returns: worksheet's UID
1093
        :rtype: UID as string
1094
        """
1095
        return self.getRawWorksheetTemplate()
1096
1097
    def getWorksheetTemplateTitle(self):
1098
        """
1099
        Returns the template's Title assigned to this worksheet
1100
        :returns: worksheet's Title
1101
        :rtype: string
1102
        """
1103
        ws = self.getWorksheetTemplate()
1104
        if ws:
1105
            return ws.Title()
1106
        return ''
1107
1108
    def getWorksheetTemplateURL(self):
1109
        """
1110
        Returns the template's URL assigned to this worksheet
1111
        :returns: worksheet's URL
1112
        :rtype: string
1113
        """
1114
        ws = self.getWorksheetTemplate()
1115
        if ws:
1116
            return ws.absolute_url_path()
1117
        return ''
1118
1119
    def getQCAnalyses(self):
1120
        """
1121
        Return the Quality Control analyses.
1122
        :returns: a list of QC analyses
1123
        :rtype: List of ReferenceAnalysis/DuplicateAnalysis
1124
        """
1125
        qc_types = ['ReferenceAnalysis', 'DuplicateAnalysis']
1126
        analyses = self.getAnalyses()
1127
        return [a for a in analyses if a.portal_type in qc_types]
1128
1129
    def getDuplicateAnalyses(self):
1130
        """Return the duplicate analyses assigned to the current worksheet
1131
        :return: List of DuplicateAnalysis
1132
        :rtype: List of IDuplicateAnalysis objects"""
1133
        ans = self.getAnalyses()
1134
        duplicates = [an for an in ans if IDuplicateAnalysis.providedBy(an)]
1135
        return duplicates
1136
1137
    def getReferenceAnalyses(self):
1138
        """Return the reference analyses (controls) assigned to the current
1139
        worksheet
1140
        :return: List of reference analyses
1141
        :rtype: List of IReferenceAnalysis objects"""
1142
        ans = self.getAnalyses()
1143
        references = [an for an in ans if IReferenceAnalysis.providedBy(an)]
1144
        return references
1145
1146
    def getRegularAnalyses(self):
1147
        """
1148
        Return the analyses assigned to the current worksheet that are directly
1149
        associated to an Analysis Request but are not QC analyses. This is all
1150
        analyses that implement IRoutineAnalysis
1151
        :return: List of regular analyses
1152
        :rtype: List of ReferenceAnalysis/DuplicateAnalysis
1153
        """
1154
        qc_types = ['ReferenceAnalysis', 'DuplicateAnalysis']
1155
        analyses = self.getAnalyses()
1156
        return [a for a in analyses if a.portal_type not in qc_types]
1157
1158
    def getNumberOfQCAnalyses(self):
1159
        """
1160
        Returns the number of Quality Control analyses.
1161
        :returns: number of QC analyses
1162
        :rtype: integer
1163
        """
1164
        return len(self.getQCAnalyses())
1165
1166
    def getNumberOfRegularAnalyses(self):
1167
        """
1168
        Returns the number of Regular analyses.
1169
        :returns: number of analyses
1170
        :rtype: integer
1171
        """
1172
        return len(self.getRegularAnalyses())
1173
1174
    def getNumberOfQCSamples(self):
1175
        """
1176
        Returns the number of Quality Control samples.
1177
        :returns: number of QC samples
1178
        :rtype: integer
1179
        """
1180
        qc_analyses = self.getQCAnalyses()
1181
        qc_samples = [a.getSample().UID() for a in qc_analyses]
1182
        # discarding any duplicate values
1183
        return len(set(qc_samples))
1184
1185
    def getNumberOfRegularSamples(self):
1186
        """
1187
        Returns the number of regular samples.
1188
        :returns: number of regular samples
1189
        :rtype: integer
1190
        """
1191
        analyses = self.getRegularAnalyses()
1192
        samples = [a.getRequestUID() for a in analyses]
1193
        # discarding any duplicate values
1194
        return len(set(samples))
1195
1196
    def setInstrument(self, instrument, override_analyses=False):
1197
        """Assigns the specified analytical instrument to the analyses in this
1198
        worksheet that are compatible with the instrument. The system will
1199
        attempt to assign the first method supported by the instrument that is
1200
        also compatible with each analysis.
1201
1202
        By default, the instrument and method assigned to the analysis won't be
1203
        replaced unless the analysis does not have an instrument assigned yet
1204
        or the parameter override_analyses is set to True. Analyses that are
1205
        incompatible with the specified instrument will remain unchanged.
1206
        """
1207
        analyses = self.getAnalyses()
1208
        instrument = api.get_object(instrument, default=None)
1209
1210
        # find out the methods supported by the instrument, if any
1211
        supported_methods = instrument.getRawMethods() if instrument else []
1212
1213
        total = 0
1214
        for an in analyses:
1215
1216
            if not override_analyses and an.getRawInstrument():
1217
                # skip, no overwrite analysis if an instrument is set
1218
                continue
1219
1220
            if not an.isInstrumentAllowed(instrument):
1221
                # skip, instrument cannot run this analysis
1222
                continue
1223
1224
            # assign the instrument
1225
            an.setInstrument(instrument)
1226
            total += 1
1227
1228
            if an.getRawMethod() in supported_methods:
1229
                # the analysis method is supported by this instrument
1230
                continue
1231
1232
            # reset and try to assign the first supported method
1233
            allowed = an.getRawAllowedMethods()
1234
            methods = list(filter(lambda m: m in allowed, supported_methods))
0 ignored issues
show
introduced by
The variable allowed does not seem to be defined for all execution paths.
Loading history...
1235
            method = methods[0] if methods else None
1236
            an.setMethod(method)
1237
1238
        self.getField('Instrument').set(self, instrument)
1239
        return total
1240
1241
    def setMethod(self, method, override_analyses=False):
1242
        """ Sets the specified method to the Analyses from the
1243
            Worksheet. Only sets the method if the Analysis
1244
            allows to keep the integrity.
1245
            If an analysis has already been assigned to a method, it won't
1246
            be overriden.
1247
            Returns the number of analyses affected.
1248
        """
1249
        analyses = [an for an in self.getAnalyses()
1250
                    if (not an.getMethod() or
1251
                        not an.getInstrument() or
1252
                        override_analyses) and an.isMethodAllowed(method)]
1253
        total = 0
1254
        for an in analyses:
1255
            success = False
1256
            if an.isMethodAllowed(method):
1257
                success = an.setMethod(method)
1258
            if success is True:
1259
                total += 1
1260
1261
        self.getField('Method').set(self, method)
1262
        return total
1263
1264
    def getAnalystName(self):
1265
        """ Returns the name of the currently assigned analyst
1266
        """
1267
        mtool = getToolByName(self, 'portal_membership')
1268
        analyst = self.getAnalyst().strip()
1269
        analyst_member = mtool.getMemberById(analyst)
1270
        if analyst_member is not None:
1271
            return analyst_member.getProperty('fullname')
1272
        return analyst
1273
1274
    # TODO Workflow - Worksheet - Move to workflow.worksheet.events
1275
    def workflow_script_reject(self):
1276
        """Copy real analyses to RejectAnalysis, with link to real
1277
           create a new worksheet, with the original analyses, and new
1278
           duplicates and references to match the rejected
1279
           worksheet.
1280
        """
1281
        if skip(self, "reject"):
1282
            return
1283
        workflow = self.portal_workflow
1284
1285
        def copy_src_fields_to_dst(src, dst):
1286
            # These will be ignored when copying field values between analyses
1287
            ignore_fields = [
1288
                'UID',
1289
                'id',
1290
                'title',
1291
                'allowDiscussion',
1292
                'subject',
1293
                'description',
1294
                'location',
1295
                'contributors',
1296
                'creators',
1297
                'effectiveDate',
1298
                'expirationDate',
1299
                'language',
1300
                'rights',
1301
                'creation_date',
1302
                'modification_date',
1303
                'Layout',    # ws
1304
                'Analyses',  # ws
1305
            ]
1306
            fields = src.Schema().fields()
1307
            for field in fields:
1308
                fieldname = field.getName()
1309
                if fieldname in ignore_fields:
1310
                    continue
1311
                getter = getattr(src, 'get' + fieldname,
1312
                                 src.Schema().getField(fieldname).getAccessor(src))
1313
                setter = getattr(dst, 'set' + fieldname,
1314
                                 dst.Schema().getField(fieldname).getMutator(dst))
1315
                if getter is None or setter is None:
1316
                    # ComputedField
1317
                    continue
1318
                setter(getter())
1319
1320
        analysis_positions = {}
1321
        for item in self.getLayout():
1322
            analysis_positions[item['analysis_uid']] = item['position']
1323
        old_layout = []
1324
        new_layout = []
1325
1326
        # New worksheet
1327
        worksheets = self.aq_parent
1328
        new_ws = _createObjectByType('Worksheet', worksheets, tmpID())
1329
        new_ws.unmarkCreationFlag()
1330
        new_ws_id = renameAfterCreation(new_ws)
1331
        copy_src_fields_to_dst(self, new_ws)
1332
        new_ws.edit(
1333
            Number=new_ws_id,
1334
            Remarks=self.getRemarks()
1335
        )
1336
1337
        # Objects are being created inside other contexts, but we want their
1338
        # workflow handlers to be aware of which worksheet this is occurring in.
1339
        # We save the worksheet in request['context_uid'].
1340
        # We reset it again below....  be very sure that this is set to the
1341
        # UID of the containing worksheet before invoking any transitions on
1342
        # analyses.
1343
        self.REQUEST['context_uid'] = new_ws.UID()
1344
1345
        # loop all analyses
1346
        analyses = self.getAnalyses()
1347
        new_ws_analyses = []
1348
        old_ws_analyses = []
1349
        for analysis in analyses:
1350
            # Skip published or verified analyses
1351
            review_state = workflow.getInfoFor(analysis, 'review_state', '')
1352
            if review_state in ['published', 'verified', 'retracted']:
1353
                old_ws_analyses.append(analysis.UID())
1354
                old_layout.append({'type': 'a',
1355
                                   'analysis_uid': analysis.UID(),
1356
                                   'container_uid': analysis.aq_parent.UID()})
1357
                continue
1358
            # Normal analyses:
1359
            # - Create matching RejectAnalysis inside old WS
1360
            # - Link analysis to new WS in same position
1361
            # - Copy all field values
1362
            # - Clear analysis result, and set Retested flag
1363
            if analysis.portal_type == 'Analysis':
1364
                reject = _createObjectByType('RejectAnalysis', self, tmpID())
1365
                reject.unmarkCreationFlag()
1366
                copy_src_fields_to_dst(analysis, reject)
1367
                reject.setAnalysis(analysis)
1368
                reject.reindexObject()
1369
                analysis.edit(
1370
                    Result=None,
1371
                    Retested=True,
1372
                )
1373
                analysis.reindexObject()
1374
                position = analysis_positions[analysis.UID()]
1375
                old_ws_analyses.append(reject.UID())
1376
                old_layout.append({'position': position,
1377
                                   'type': 'r',
1378
                                   'analysis_uid': reject.UID(),
1379
                                   'container_uid': self.UID()})
1380
                new_ws_analyses.append(analysis.UID())
1381
                new_layout.append({'position': position,
1382
                                   'type': 'a',
1383
                                   'analysis_uid': analysis.UID(),
1384
                                   'container_uid': analysis.aq_parent.UID()})
1385
            # Reference analyses
1386
            # - Create a new reference analysis in the new worksheet
1387
            # - Transition the original analysis to 'rejected' state
1388
            if analysis.portal_type == 'ReferenceAnalysis':
1389
                service_uid = analysis.getServiceUID()
1390
                reference = analysis.aq_parent
1391
                new_reference = create_reference_analysis(reference, service_uid)
1392
                reference_type = new_reference.getReferenceType()
1393
                new_analysis_uid = api.get_uid(new_reference)
1394
                position = analysis_positions[analysis.UID()]
1395
                old_ws_analyses.append(analysis.UID())
1396
                old_layout.append({'position': position,
1397
                                   'type': reference_type,
1398
                                   'analysis_uid': analysis.UID(),
1399
                                   'container_uid': reference.UID()})
1400
                new_ws_analyses.append(new_analysis_uid)
1401
                new_layout.append({'position': position,
1402
                                   'type': reference_type,
1403
                                   'analysis_uid': new_analysis_uid,
1404
                                   'container_uid': reference.UID()})
1405
                workflow.doActionFor(analysis, 'reject')
1406
                analysis.reindexObject()
1407
            # Duplicate analyses
1408
            # - Create a new duplicate inside the new worksheet
1409
            # - Transition the original analysis to 'rejected' state
1410
            if analysis.portal_type == 'DuplicateAnalysis':
1411
                duplicate_id = new_ws.generateUniqueId('DuplicateAnalysis')
1412
                new_duplicate = _createObjectByType('DuplicateAnalysis',
1413
                                                    new_ws, duplicate_id)
1414
                new_duplicate.unmarkCreationFlag()
1415
                copy_src_fields_to_dst(analysis, new_duplicate)
1416
                new_duplicate.reindexObject()
1417
                position = analysis_positions[analysis.UID()]
1418
                old_ws_analyses.append(analysis.UID())
1419
                old_layout.append({'position': position,
1420
                                   'type': 'd',
1421
                                   'analysis_uid': analysis.UID(),
1422
                                   'container_uid': self.UID()})
1423
                new_ws_analyses.append(new_duplicate.UID())
1424
                new_layout.append({'position': position,
1425
                                   'type': 'd',
1426
                                   'analysis_uid': new_duplicate.UID(),
1427
                                   'container_uid': new_ws.UID()})
1428
                workflow.doActionFor(analysis, 'reject')
1429
                analysis.reindexObject()
1430
1431
        new_ws.setAnalyses(new_ws_analyses)
1432
        new_ws.setLayout(new_layout)
1433
        new_ws.replaces_rejected_worksheet = self.UID()
1434
        for analysis in new_ws.getAnalyses():
1435
            review_state = workflow.getInfoFor(analysis, 'review_state', '')
1436
            if review_state == 'to_be_verified':
1437
                # TODO Workflow - Analysis Retest transition within a Worksheet
1438
                changeWorkflowState(analysis, ANALYSIS_WORKFLOW, "assigned")
1439
        self.REQUEST['context_uid'] = self.UID()
1440
        self.setLayout(old_layout)
1441
        self.setAnalyses(old_ws_analyses)
1442
        self.replaced_by = new_ws.UID()
1443
1444
    # TODO Workflow - Worksheet - Remove this function
1445
    def checkUserManage(self):
1446
        """ Checks if the current user has granted access to this worksheet
1447
            and if has also privileges for managing it.
1448
        """
1449
        granted = False
1450
        can_access = self.checkUserAccess()
1451
1452
        if can_access is True:
1453
            pm = getToolByName(self, 'portal_membership')
1454
            if can_edit_worksheet(self):
1455
                # Check if the current user is the WS's current analyst
1456
                member = pm.getAuthenticatedMember()
1457
                analyst = self.getAnalyst().strip()
1458
                if analyst != _c(member.getId()):
1459
                    # Has management privileges?
1460
                    if can_manage_worksheets(self):
1461
                        granted = True
1462
                else:
1463
                    granted = True
1464
1465
        return granted
1466
1467
    # TODO Workflow - Worksheet - Remove this function
1468
    def checkUserAccess(self):
1469
        """ Checks if the current user has granted access to this worksheet.
1470
            Returns False if the user has no access, otherwise returns True
1471
        """
1472
        # Deny access to foreign analysts
1473
        allowed = True
1474
        pm = getToolByName(self, "portal_membership")
1475
        member = pm.getAuthenticatedMember()
1476
1477
        analyst = self.getAnalyst().strip()
1478
        if analyst != _c(member.getId()):
1479
            roles = member.getRoles()
1480
            restrict = 'Manager' not in roles \
1481
                and 'LabManager' not in roles \
1482
                and 'LabClerk' not in roles \
1483
                and 'RegulatoryInspector' not in roles \
1484
                and self.bika_setup.getRestrictWorksheetUsersAccess()
1485
            allowed = not restrict
1486
1487
        return allowed
1488
1489
    def setAnalyst(self, analyst):
1490
        for analysis in self.getAnalyses():
1491
            analysis.setAnalyst(analyst)
1492
        self.Schema().getField('Analyst').set(self, analyst)
1493
        self.reindexObject()
1494
1495
    def getAnalysesUIDs(self):
1496
        """
1497
        Returns the analyses UIDs from the analyses assigned to this worksheet
1498
        :returns: a list of UIDs
1499
        :rtype: a list of strings
1500
        """
1501
        analyses = self.getAnalyses()
1502
        if isinstance(analyses, list):
1503
            return [an.UID() for an in analyses]
1504
        return []
1505
1506
    def getProgressPercentage(self):
1507
        """Returns the progress percentage of this worksheet
1508
        """
1509
        state = api.get_workflow_status_of(self)
1510
        if state == "verified":
1511
            return 100
1512
1513
        steps = 0
1514
        query = dict(getWorksheetUID=api.get_uid(self))
1515
        analyses = api.search(query, ANALYSIS_CATALOG)
1516
        max_steps = len(analyses) * 2
1517
        for analysis in analyses:
1518
            an_state = analysis.review_state
1519
            if an_state in ["rejected", "retracted", "cancelled"]:
1520
                steps += 2
1521
            elif an_state in ["verified", "published"]:
1522
                steps += 2
1523
            elif an_state == "to_be_verified":
1524
                steps += 1
1525
        if steps == 0:
1526
            return 0
1527
        if steps > max_steps:
1528
            return 100
1529
        return (steps * 100)/max_steps
1530
1531
registerType(Worksheet, PROJECTNAME)
1532