Passed
Push — 2.x ( cc5206...47d21d )
by Jordi
06:16
created

AnalysisRequest.getReports()   A

Complexity

Conditions 1

Size

Total Lines 6
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nop 1
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-2021 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import base64
22
import functools
23
import re
24
from decimal import Decimal
25
26
from six.moves.urllib.parse import urljoin
27
28
from AccessControl import ClassSecurityInfo
29
from bika.lims import api
30
from bika.lims import bikaMessageFactory as _
31
from bika.lims import deprecated
32
from bika.lims import logger
33
from bika.lims.api.security import check_permission
34
from bika.lims.browser.fields import ARAnalysesField
35
from bika.lims.browser.fields import DurationField
36
from bika.lims.browser.fields import EmailsField
37
from bika.lims.browser.fields import ResultsRangesField
38
from bika.lims.browser.fields import UIDReferenceField
39
from bika.lims.browser.fields.remarksfield import RemarksField
40
from bika.lims.browser.fields.uidreferencefield import get_backreferences
41
from bika.lims.browser.widgets import DateTimeWidget
42
from bika.lims.browser.widgets import DecimalWidget
43
from bika.lims.browser.widgets import PrioritySelectionWidget
44
from bika.lims.browser.widgets import ReferenceWidget
45
from bika.lims.browser.widgets import RejectionWidget
46
from bika.lims.browser.widgets import RemarksWidget
47
from bika.lims.browser.widgets import SelectionWidget as BikaSelectionWidget
48
from bika.lims.browser.widgets.durationwidget import DurationWidget
49
from bika.lims.config import PRIORITIES
50
from bika.lims.config import PROJECTNAME
51
from bika.lims.content.bikaschema import BikaSchema
52
from bika.lims.content.clientawaremixin import ClientAwareMixin
53
from bika.lims.interfaces import IAnalysisRequest
54
from bika.lims.interfaces import IAnalysisRequestPartition
55
from bika.lims.interfaces import IAnalysisRequestWithPartitions
56
from bika.lims.interfaces import IBatch
57
from bika.lims.interfaces import ICancellable
58
from bika.lims.interfaces import IClient
59
from bika.lims.interfaces import IDynamicResultsRange
60
from bika.lims.interfaces import ISubmitted
61
from bika.lims.permissions import FieldEditBatch
62
from bika.lims.permissions import FieldEditClient
63
from bika.lims.permissions import FieldEditClientOrderNumber
64
from bika.lims.permissions import FieldEditClientReference
65
from bika.lims.permissions import FieldEditClientSampleID
66
from bika.lims.permissions import FieldEditComposite
67
from bika.lims.permissions import FieldEditContact
68
from bika.lims.permissions import FieldEditContainer
69
from bika.lims.permissions import FieldEditDatePreserved
70
from bika.lims.permissions import FieldEditDateReceived
71
from bika.lims.permissions import FieldEditDateSampled
72
from bika.lims.permissions import FieldEditEnvironmentalConditions
73
from bika.lims.permissions import FieldEditInternalUse
74
from bika.lims.permissions import FieldEditInvoiceExclude
75
from bika.lims.permissions import FieldEditMemberDiscount
76
from bika.lims.permissions import FieldEditPreservation
77
from bika.lims.permissions import FieldEditPreserver
78
from bika.lims.permissions import FieldEditPriority
79
from bika.lims.permissions import FieldEditProfiles
80
from bika.lims.permissions import FieldEditPublicationSpecifications
81
from bika.lims.permissions import FieldEditRejectionReasons
82
from bika.lims.permissions import FieldEditRemarks
83
from bika.lims.permissions import FieldEditResultsInterpretation
84
from bika.lims.permissions import FieldEditSampleCondition
85
from bika.lims.permissions import FieldEditSamplePoint
86
from bika.lims.permissions import FieldEditSampler
87
from bika.lims.permissions import FieldEditSampleType
88
from bika.lims.permissions import FieldEditSamplingDate
89
from bika.lims.permissions import FieldEditSamplingDeviation
90
from bika.lims.permissions import FieldEditScheduledSampler
91
from bika.lims.permissions import FieldEditSpecification
92
from bika.lims.permissions import FieldEditStorageLocation
93
from bika.lims.permissions import FieldEditTemplate
94
from bika.lims.permissions import ManageInvoices
95
from bika.lims.utils import getUsers
96
from bika.lims.utils import tmpID
97
from bika.lims.utils import user_email
98
from bika.lims.utils import user_fullname
99
from bika.lims.workflow import getTransitionDate
100
from bika.lims.workflow import getTransitionUsers
101
from DateTime import DateTime
102
from Products.Archetypes.atapi import BaseFolder
103
from Products.Archetypes.atapi import BooleanField
104
from Products.Archetypes.atapi import BooleanWidget
105
from Products.Archetypes.atapi import ComputedField
106
from Products.Archetypes.atapi import ComputedWidget
107
from Products.Archetypes.atapi import FileField
108
from Products.Archetypes.atapi import FileWidget
109
from Products.Archetypes.atapi import FixedPointField
110
from Products.Archetypes.atapi import StringField
111
from Products.Archetypes.atapi import StringWidget
112
from Products.Archetypes.atapi import TextField
113
from Products.Archetypes.atapi import registerType
114
from Products.Archetypes.config import UID_CATALOG
115
from Products.Archetypes.Field import IntegerField
116
from Products.Archetypes.public import Schema
117
from Products.Archetypes.Widget import IntegerWidget
118
from Products.Archetypes.Widget import RichWidget
119
from Products.CMFCore.permissions import ModifyPortalContent
120
from Products.CMFCore.permissions import View
121
from Products.CMFCore.utils import getToolByName
122
from Products.CMFPlone.utils import _createObjectByType
123
from Products.CMFPlone.utils import safe_unicode
124
from senaite.core.browser.fields.datetime import DateTimeField
125
from senaite.core.browser.fields.records import RecordsField
126
from senaite.core.catalog import ANALYSIS_CATALOG
127
from senaite.core.catalog import CLIENT_CATALOG
128
from senaite.core.catalog import SAMPLE_CATALOG
129
from senaite.core.catalog import SENAITE_CATALOG
130
from senaite.core.catalog import WORKSHEET_CATALOG
131
from zope.interface import alsoProvides
132
from zope.interface import implements
133
from zope.interface import noLongerProvides
134
135
IMG_SRC_RX = re.compile(r'<img.*?src="(.*?)"')
136
IMG_DATA_SRC_RX = re.compile(r'<img.*?src="(data:image/.*?;base64,)(.*?)"')
137
FINAL_STATES = ["published", "retracted", "rejected", "cancelled"]
138
139
140
# SCHEMA DEFINITION
141
schema = BikaSchema.copy() + Schema((
142
143
    UIDReferenceField(
144
        'Contact',
145
        required=1,
146
        allowed_types=('Contact',),
147
        mode="rw",
148
        read_permission=View,
149
        write_permission=FieldEditContact,
150
        widget=ReferenceWidget(
151
            label=_("Contact"),
152
            render_own_label=True,
153
            size=20,
154
            description=_("The primary contact of this sample, "
155
                          "who will receive notifications and publications "
156
                          "via email"),
157
            visible={
158
                'add': 'edit',
159
                'header_table': 'prominent',
160
            },
161
            catalog_name="portal_catalog",
162
            base_query={"is_active": True,
163
                        "sort_limit": 50,
164
                        "sort_on": "sortable_title",
165
                        "getParentUID": "",
166
                        "sort_order": "ascending"},
167
            showOn=True,
168
            popup_width='400px',
169
            colModel=[
170
                {'columnName': 'Fullname', 'width': '50',
171
                 'label': _('Name')},
172
                {'columnName': 'EmailAddress', 'width': '50',
173
                 'label': _('Email Address')},
174
            ],
175
            ui_item='Fullname',
176
        ),
177
    ),
178
179
    UIDReferenceField(
180
        'CCContact',
181
        multiValued=1,
182
        allowed_types=('Contact',),
183
        mode="rw",
184
        read_permission=View,
185
        write_permission=FieldEditContact,
186
        widget=ReferenceWidget(
187
            label=_("CC Contacts"),
188
            description=_("The contacts used in CC for email notifications"),
189
            render_own_label=True,
190
            size=20,
191
            visible={
192
                'add': 'edit',
193
                'header_table': 'prominent',
194
            },
195
            catalog_name="portal_catalog",
196
            base_query={"is_active": True,
197
                        "sort_on": "sortable_title",
198
                        "getParentUID": "",
199
                        "sort_order": "ascending"},
200
            showOn=True,
201
            popup_width='400px',
202
            colModel=[
203
                {'columnName': 'Fullname', 'width': '50',
204
                 'label': _('Name')},
205
                {'columnName': 'EmailAddress', 'width': '50',
206
                 'label': _('Email Address')},
207
            ],
208
            ui_item='Fullname',
209
        ),
210
    ),
211
212
    EmailsField(
213
        'CCEmails',
214
        mode="rw",
215
        read_permission=View,
216
        write_permission=FieldEditContact,
217
        widget=StringWidget(
218
            label=_("CC Emails"),
219
            description=_("Additional email addresses to be notified"),
220
            visible={
221
                'add': 'edit',
222
                'header_table': 'prominent',
223
            },
224
            render_own_label=True,
225
            size=20,
226
        ),
227
    ),
228
229
    UIDReferenceField(
230
        'Client',
231
        required=1,
232
        allowed_types=('Client',),
233
        mode="rw",
234
        read_permission=View,
235
        write_permission=FieldEditClient,
236
        widget=ReferenceWidget(
237
            label=_("Client"),
238
            description=_("The assigned client of this request"),
239
            size=20,
240
            render_own_label=True,
241
            visible={
242
                'add': 'edit',
243
                'header_table': 'prominent',
244
            },
245
            catalog_name=CLIENT_CATALOG,
246
            base_query={"is_active": True,
247
                        "sort_limit": 30,
248
                        "sort_on": "sortable_title",
249
                        "sort_order": "ascending"},
250
            colModel=[
251
                {"columnName": "getName", "width": "70", "label": _(
252
                    "Client Name"), "align": "left"},
253
                {"columnName": "getClientID", "width": "30", "label": _(
254
                    "Client ID"), "align": "left"},
255
                # UID is required in colModel
256
                {"columnName": "UID", "hidden": True},
257
            ],
258
            ui_item="getName",
259
            showOn=True,
260
        ),
261
    ),
262
263
    # Field for the creation of Secondary Analysis Requests.
264
    # This field is meant to be displayed in AR Add form only. A viewlet exists
265
    # to inform the user this Analysis Request is secondary
266
    UIDReferenceField(
267
        "PrimaryAnalysisRequest",
268
        allowed_types=("AnalysisRequest",),
269
        relationship='AnalysisRequestPrimaryAnalysisRequest',
270
        mode="rw",
271
        read_permission=View,
272
        write_permission=FieldEditClient,
273
        widget=ReferenceWidget(
274
            label=_("Primary Sample"),
275
            description=_("Select a sample to create a secondary Sample"),
276
            size=20,
277
            render_own_label=True,
278
            visible={
279
                'add': 'edit',
280
                'header_table': 'prominent',
281
            },
282
            catalog_name=SAMPLE_CATALOG,
283
            search_fields=('listing_searchable_text',),
284
            base_query={'is_active': True,
285
                        'is_received': True,
286
                        'sort_limit': 30,
287
                        'sort_on': 'getId',
288
                        'sort_order': 'descending'},
289
            colModel=[
290
                {'columnName': 'getId', 'width': '20',
291
                 'label': _('Sample ID'), 'align': 'left'},
292
                {'columnName': 'getClientSampleID', 'width': '20',
293
                 'label': _('Client SID'), 'align': 'left'},
294
                {'columnName': 'getSampleTypeTitle', 'width': '30',
295
                 'label': _('Sample Type'), 'align': 'left'},
296
                {'columnName': 'getClientTitle', 'width': '30',
297
                 'label': _('Client'), 'align': 'left'},
298
                {'columnName': 'UID', 'hidden': True},
299
            ],
300
            ui_item='getId',
301
            showOn=True,
302
        )
303
    ),
304
305
    UIDReferenceField(
306
        'Batch',
307
        allowed_types=('Batch',),
308
        mode="rw",
309
        read_permission=View,
310
        write_permission=FieldEditBatch,
311
        widget=ReferenceWidget(
312
            label=_("Batch"),
313
            size=20,
314
            description=_("The assigned batch of this request"),
315
            render_own_label=True,
316
            visible={
317
                'add': 'edit',
318
            },
319
            catalog_name=SENAITE_CATALOG,
320
            search_fields=('listing_searchable_text',),
321
            base_query={"is_active": True,
322
                        "sort_limit": 50,
323
                        "sort_on": "sortable_title",
324
                        "sort_order": "descending"},
325
            colModel=[
326
                {'columnName': 'getId', 'width': '20',
327
                 'label': _('Batch ID'), 'align': 'left'},
328
                {'columnName': 'Title', 'width': '20',
329
                 'label': _('Title'), 'align': 'left'},
330
                {'columnName': 'getClientBatchID', 'width': '20',
331
                 'label': _('CBID'), 'align': 'left'},
332
                {'columnName': 'getClientTitle', 'width': '30',
333
                 'label': _('Client'), 'align': 'left'},
334
            ],
335
            force_all=False,
336
            ui_item="getId",
337
            showOn=True,
338
        ),
339
    ),
340
341
    UIDReferenceField(
342
        'SubGroup',
343
        required=False,
344
        allowed_types=('SubGroup',),
345
        mode="rw",
346
        read_permission=View,
347
        write_permission=FieldEditBatch,
348
        widget=ReferenceWidget(
349
            label=_("Batch Sub-group"),
350
            description=_("The assigned batch sub group of this request"),
351
            size=20,
352
            render_own_label=True,
353
            visible={
354
                'add': 'edit',
355
            },
356
            catalog_name='senaite_catalog_setup',
357
            colModel=[
358
                {'columnName': 'Title', 'width': '30',
359
                 'label': _('Title'), 'align': 'left'},
360
                {'columnName': 'Description', 'width': '70',
361
                 'label': _('Description'), 'align': 'left'},
362
                {'columnName': 'SortKey', 'hidden': True},
363
                {'columnName': 'UID', 'hidden': True},
364
            ],
365
            base_query={'is_active': True},
366
            sidx='SortKey',
367
            sord='asc',
368
            showOn=True,
369
        ),
370
    ),
371
372
    UIDReferenceField(
373
        'Template',
374
        allowed_types=('ARTemplate',),
375
        mode="rw",
376
        read_permission=View,
377
        write_permission=FieldEditTemplate,
378
        widget=ReferenceWidget(
379
            label=_("Sample Template"),
380
            description=_(
381
                "The predefined values of the Sample template are set in the "
382
                "request"
383
            ),
384
            size=20,
385
            render_own_label=True,
386
            visible={
387
                'add': 'edit',
388
                'secondary': 'disabled',
389
            },
390
            catalog_name='senaite_catalog_setup',
391
            base_query={"is_active": True,
392
                        "sort_on": "sortable_title",
393
                        "sort_order": "ascending"},
394
            showOn=True,
395
        ),
396
    ),
397
398
    UIDReferenceField(
399
        'Profiles',
400
        multiValued=1,
401
        allowed_types=('AnalysisProfile',),
402
        mode="rw",
403
        read_permission=View,
404
        write_permission=FieldEditProfiles,
405
        widget=ReferenceWidget(
406
            label=_("Analysis Profiles"),
407
            description=_("Analysis profiles apply a certain set of analyses"),
408
            size=20,
409
            render_own_label=True,
410
            visible={
411
                'add': 'edit',
412
            },
413
            catalog_name='senaite_catalog_setup',
414
            base_query={"is_active": True,
415
                        "sort_on": "sortable_title",
416
                        "sort_order": "ascending"},
417
            showOn=True,
418
        ),
419
    ),
420
    # TODO Workflow - Request - Fix DateSampled inconsistencies. At the moment,
421
    # one can create an AR (with code) with DateSampled set when sampling_wf at
422
    # the same time sampling workflow is active. This might cause
423
    # inconsistencies: AR still in `to_be_sampled`, but getDateSampled returns
424
    # a valid date!
425
    DateTimeField(
426
        'DateSampled',
427
        mode="rw",
428
        read_permission=View,
429
        write_permission=FieldEditDateSampled,
430
        widget=DateTimeWidget(
431
            label=_("Date Sampled"),
432
            description=_("The date when the sample was taken"),
433
            size=20,
434
            show_time=True,
435
            datepicker_nofuture=1,
436
            visible={
437
                'add': 'edit',
438
                'secondary': 'disabled',
439
                'header_table': 'prominent',
440
            },
441
            render_own_label=True,
442
        ),
443
    ),
444
    StringField(
445
        'Sampler',
446
        mode="rw",
447
        read_permission=View,
448
        write_permission=FieldEditSampler,
449
        vocabulary='getSamplers',
450
        widget=BikaSelectionWidget(
451
            format='select',
452
            label=_("Sampler"),
453
            description=_("The person who took the sample"),
454
            # see SamplingWOrkflowWidgetVisibility
455
            visible={
456
                'add': 'edit',
457
                'header_table': 'prominent',
458
            },
459
            render_own_label=True,
460
        ),
461
    ),
462
463
    StringField(
464
        'ScheduledSamplingSampler',
465
        mode="rw",
466
        read_permission=View,
467
        write_permission=FieldEditScheduledSampler,
468
        vocabulary='getSamplers',
469
        widget=BikaSelectionWidget(
470
            description=_("Define the sampler supposed to do the sample in "
471
                          "the scheduled date"),
472
            format='select',
473
            label=_("Sampler for scheduled sampling"),
474
            visible={
475
                'add': 'edit',
476
            },
477
            render_own_label=True,
478
        ),
479
    ),
480
481
    DateTimeField(
482
        'SamplingDate',
483
        mode="rw",
484
        read_permission=View,
485
        write_permission=FieldEditSamplingDate,
486
        widget=DateTimeWidget(
487
            label=_("Expected Sampling Date"),
488
            description=_("The date when the sample will be taken"),
489
            size=20,
490
            show_time=True,
491
            datepicker_nopast=1,
492
            render_own_label=True,
493
            visible={
494
                'add': 'edit',
495
                'secondary': 'disabled',
496
            },
497
        ),
498
    ),
499
500
    UIDReferenceField(
501
        'SampleType',
502
        required=1,
503
        allowed_types='SampleType',
504
        mode="rw",
505
        read_permission=View,
506
        write_permission=FieldEditSampleType,
507
        widget=ReferenceWidget(
508
            label=_("Sample Type"),
509
            render_own_label=True,
510
            visible={
511
                'add': 'edit',
512
                'secondary': 'disabled',
513
            },
514
            catalog_name='senaite_catalog_setup',
515
            base_query={"is_active": True,
516
                        "sort_on": "sortable_title",
517
                        "sort_order": "ascending"},
518
            showOn=True,
519
        ),
520
    ),
521
522
    UIDReferenceField(
523
        'Container',
524
        required=0,
525
        allowed_types='SampleContainer',
526
        mode="rw",
527
        read_permission=View,
528
        write_permission=FieldEditContainer,
529
        widget=ReferenceWidget(
530
            label=_("Container"),
531
            size=20,
532
            render_own_label=True,
533
            visible={
534
                'add': 'edit',
535
            },
536
            catalog_name='senaite_catalog_setup',
537
            base_query={
538
                "portal_type": "SampleContainer",
539
                "is_active": True,
540
                "sort_on": "sortable_title",
541
                "sort_order": "ascending",
542
            },
543
            showOn=True,
544
        ),
545
    ),
546
547
    UIDReferenceField(
548
        'Preservation',
549
        required=0,
550
        allowed_types='Preservation',
551
        mode="rw",
552
        read_permission=View,
553
        write_permission=FieldEditPreservation,
554
        widget=ReferenceWidget(
555
            label=_("Preservation"),
556
            size=20,
557
            render_own_label=True,
558
            visible={
559
                'add': 'edit',
560
            },
561
            catalog_name='senaite_catalog_setup',
562
            base_query={"is_active": True,
563
                        "sort_on": "sortable_title",
564
                        "sort_order": "ascending"},
565
            showOn=True,
566
        ),
567
    ),
568
569
    DateTimeField(
570
        "DatePreserved",
571
        mode="rw",
572
        read_permission=View,
573
        write_permission=FieldEditDatePreserved,
574
        widget=DateTimeWidget(
575
            label=_("Date Preserved"),
576
            description=_("The date when the sample was preserved"),
577
            size=20,
578
            show_time=True,
579
            render_own_label=True,
580
            visible={
581
                'add': 'edit',
582
                'header_table': 'prominent',
583
            },
584
        ),
585
    ),
586
    StringField(
587
        "Preserver",
588
        required=0,
589
        mode="rw",
590
        read_permission=View,
591
        write_permission=FieldEditPreserver,
592
        vocabulary='getPreservers',
593
        widget=BikaSelectionWidget(
594
            format='select',
595
            label=_("Preserver"),
596
            description=_("The person who preserved the sample"),
597
            visible={
598
                'add': 'edit',
599
                'header_table': 'prominent',
600
            },
601
            render_own_label=True,
602
        ),
603
    ),
604
    # TODO Sample cleanup - This comes from partition
605
    DurationField(
606
        "RetentionPeriod",
607
        required=0,
608
        mode="r",
609
        read_permission=View,
610
        widget=DurationWidget(
611
            label=_("Retention Period"),
612
            visible=False,
613
        ),
614
    ),
615
    RecordsField(
616
        'RejectionReasons',
617
        mode="rw",
618
        read_permission=View,
619
        write_permission=FieldEditRejectionReasons,
620
        widget=RejectionWidget(
621
            label=_("Sample Rejection"),
622
            description=_("Set the Sample Rejection workflow and the reasons"),
623
            render_own_label=False,
624
            visible={
625
                'add': 'edit',
626
                'secondary': 'disabled',
627
            },
628
        ),
629
    ),
630
631
    UIDReferenceField(
632
        'Specification',
633
        required=0,
634
        primary_bound=True,  # field changes propagate to partitions
635
        allowed_types='AnalysisSpec',
636
        mode="rw",
637
        read_permission=View,
638
        write_permission=FieldEditSpecification,
639
        widget=ReferenceWidget(
640
            label=_("Analysis Specification"),
641
            description=_("Choose default Sample specification values"),
642
            size=20,
643
            render_own_label=True,
644
            visible={
645
                'add': 'edit',
646
            },
647
            catalog_name='senaite_catalog_setup',
648
            base_query={"is_active": True,
649
                        "sort_on": "sortable_title",
650
                        "sort_order": "ascending"},
651
            search_fields=('listing_searchable_text',),
652
            colModel=[
653
                {'columnName': 'contextual_title',
654
                 'width': '30',
655
                 'label': _('Title'),
656
                 'align': 'left'},
657
                {'columnName': 'getSampleTypeTitle',
658
                 'width': '70',
659
                 'label': _('SampleType'),
660
                 'align': 'left'},
661
                # UID is required in colModel
662
                {'columnName': 'UID', 'hidden': True},
663
            ],
664
            ui_item="contextual_title",
665
            showOn=True,
666
        ),
667
    ),
668
669
    # Field to keep the result ranges from the specification initially set
670
    # through "Specifications" field. This guarantees that the result ranges
671
    # set by default to this Sample won't change even if the Specifications
672
    # object referenced gets modified thereafter.
673
    # This field does not consider result ranges manually set to analyses.
674
    # Therefore, is also used to "detect" changes between the result ranges
675
    # specifically set to analyses and the results ranges set to the sample
676
    ResultsRangesField(
677
        "ResultsRange",
678
        write_permission=FieldEditSpecification,
679
        widget=ComputedWidget(visible=False),
680
    ),
681
682
    UIDReferenceField(
683
        'PublicationSpecification',
684
        required=0,
685
        allowed_types='AnalysisSpec',
686
        mode="rw",
687
        read_permission=View,
688
        write_permission=FieldEditPublicationSpecifications,
689
        widget=ReferenceWidget(
690
            label=_("Publication Specification"),
691
            description=_(
692
                "Set the specification to be used before publishing a Sample."
693
            ),
694
            size=20,
695
            render_own_label=True,
696
            visible={
697
                "add": "invisible",
698
                'secondary': 'disabled',
699
            },
700
            catalog_name='senaite_catalog_setup',
701
            base_query={"is_active": True,
702
                        "sort_on": "sortable_title",
703
                        "sort_order": "ascending"},
704
            showOn=True,
705
        ),
706
    ),
707
708
    # Sample field
709
    UIDReferenceField(
710
        'SamplePoint',
711
        allowed_types='SamplePoint',
712
        mode="rw",
713
        read_permission=View,
714
        write_permission=FieldEditSamplePoint,
715
        widget=ReferenceWidget(
716
            label=_("Sample Point"),
717
            description=_("Location where sample was taken"),
718
            size=20,
719
            render_own_label=True,
720
            visible={
721
                'add': 'edit',
722
                'secondary': 'disabled',
723
            },
724
            catalog_name='senaite_catalog_setup',
725
            base_query={"is_active": True,
726
                        "sort_on": "sortable_title",
727
                        "sort_order": "ascending"},
728
            showOn=True,
729
        ),
730
    ),
731
732
    UIDReferenceField(
733
        'StorageLocation',
734
        allowed_types='StorageLocation',
735
        mode="rw",
736
        read_permission=View,
737
        write_permission=FieldEditStorageLocation,
738
        widget=ReferenceWidget(
739
            label=_("Storage Location"),
740
            description=_("Location where sample is kept"),
741
            size=20,
742
            render_own_label=True,
743
            visible={
744
                'add': 'edit',
745
                'secondary': 'disabled',
746
            },
747
            catalog_name='senaite_catalog_setup',
748
            base_query={"is_active": True,
749
                        "sort_on": "sortable_title",
750
                        "sort_order": "ascending"},
751
            showOn=True,
752
        ),
753
    ),
754
755
    StringField(
756
        'ClientOrderNumber',
757
        mode="rw",
758
        read_permission=View,
759
        write_permission=FieldEditClientOrderNumber,
760
        widget=StringWidget(
761
            label=_("Client Order Number"),
762
            description=_("The client side order number for this request"),
763
            size=20,
764
            render_own_label=True,
765
            visible={
766
                'add': 'edit',
767
                'secondary': 'disabled',
768
            },
769
        ),
770
    ),
771
772
    StringField(
773
        'ClientReference',
774
        mode="rw",
775
        read_permission=View,
776
        write_permission=FieldEditClientReference,
777
        widget=StringWidget(
778
            label=_("Client Reference"),
779
            description=_("The client side reference for this request"),
780
            size=20,
781
            render_own_label=True,
782
            visible={
783
                'add': 'edit',
784
                'secondary': 'disabled',
785
            },
786
        ),
787
    ),
788
789
    StringField(
790
        'ClientSampleID',
791
        mode="rw",
792
        read_permission=View,
793
        write_permission=FieldEditClientSampleID,
794
        widget=StringWidget(
795
            label=_("Client Sample ID"),
796
            description=_("The client side identifier of the sample"),
797
            size=20,
798
            render_own_label=True,
799
            visible={
800
                'add': 'edit',
801
                'secondary': 'disabled',
802
            },
803
        ),
804
    ),
805
806
    UIDReferenceField(
807
        'SamplingDeviation',
808
        allowed_types='SamplingDeviation',
809
        mode="rw",
810
        read_permission=View,
811
        write_permission=FieldEditSamplingDeviation,
812
        widget=ReferenceWidget(
813
            label=_("Sampling Deviation"),
814
            description=_("Deviation between the sample and how it "
815
                          "was sampled"),
816
            size=20,
817
            render_own_label=True,
818
            visible={
819
                'add': 'edit',
820
                'secondary': 'disabled',
821
            },
822
            catalog_name='senaite_catalog_setup',
823
            base_query={"is_active": True,
824
                        "sort_on": "sortable_title",
825
                        "sort_order": "ascending"},
826
            showOn=True,
827
        ),
828
    ),
829
830
    UIDReferenceField(
831
        'SampleCondition',
832
        allowed_types='SampleCondition',
833
        mode="rw",
834
        read_permission=View,
835
        write_permission=FieldEditSampleCondition,
836
        widget=ReferenceWidget(
837
            label=_("Sample condition"),
838
            description=_("The condition of the sample"),
839
            size=20,
840
            render_own_label=True,
841
            visible={
842
                'add': 'edit',
843
                'secondary': 'disabled',
844
            },
845
            catalog_name='senaite_catalog_setup',
846
            base_query={"is_active": True,
847
                        "sort_on": "sortable_title",
848
                        "sort_order": "ascending"},
849
            showOn=True,
850
        ),
851
    ),
852
853
    StringField(
854
        'Priority',
855
        default='3',
856
        vocabulary=PRIORITIES,
857
        mode='rw',
858
        read_permission=View,
859
        write_permission=FieldEditPriority,
860
        widget=PrioritySelectionWidget(
861
            label=_('Priority'),
862
            format='select',
863
            visible={
864
                'add': 'edit',
865
            },
866
        ),
867
    ),
868
    StringField(
869
        'EnvironmentalConditions',
870
        mode="rw",
871
        read_permission=View,
872
        write_permission=FieldEditEnvironmentalConditions,
873
        widget=StringWidget(
874
            label=_("Environmental conditions"),
875
            description=_("The environmental condition during sampling"),
876
            visible={
877
                'add': 'edit',
878
                'header_table': 'prominent',
879
            },
880
            render_own_label=True,
881
            size=20,
882
        ),
883
    ),
884
885
    BooleanField(
886
        'Composite',
887
        default=False,
888
        mode="rw",
889
        read_permission=View,
890
        write_permission=FieldEditComposite,
891
        widget=BooleanWidget(
892
            label=_("Composite"),
893
            render_own_label=True,
894
            visible={
895
                'add': 'edit',
896
                'secondary': 'disabled',
897
            },
898
        ),
899
    ),
900
901
    BooleanField(
902
        'InvoiceExclude',
903
        default=False,
904
        mode="rw",
905
        read_permission=View,
906
        write_permission=FieldEditInvoiceExclude,
907
        widget=BooleanWidget(
908
            label=_("Invoice Exclude"),
909
            description=_("Should the analyses be excluded from the invoice?"),
910
            render_own_label=True,
911
            visible={
912
                'add': 'edit',
913
                'header_table': 'visible',
914
            },
915
        ),
916
    ),
917
918
    # TODO Review permission for this field Analyses
919
    ARAnalysesField(
920
        'Analyses',
921
        required=1,
922
        mode="rw",
923
        read_permission=View,
924
        write_permission=ModifyPortalContent,
925
        widget=ComputedWidget(
926
            visible={
927
                'edit': 'invisible',
928
                'view': 'invisible',
929
                'sample_registered': {
930
                    'view': 'visible', 'edit': 'visible', 'add': 'invisible'},
931
            }
932
        ),
933
    ),
934
935
    UIDReferenceField(
936
        'Attachment',
937
        multiValued=1,
938
        allowed_types=('Attachment',),
939
        relationship='AnalysisRequestAttachment',
940
        mode="rw",
941
        read_permission=View,
942
        # The addition and removal of attachments is governed by the specific
943
        # permissions "Add Sample Attachment" and "Delete Sample Attachment",
944
        # so we assume here that the write permission is the less restrictive
945
        # "ModifyPortalContent"
946
        write_permission=ModifyPortalContent,
947
        widget=ComputedWidget(
948
            visible={
949
                'edit': 'invisible',
950
                'view': 'invisible',
951
            },
952
        )
953
    ),
954
955
    # This is a virtual field and handled only by AR Add View to allow multi
956
    # attachment upload in AR Add. It should never contain an own value!
957
    FileField(
958
        '_ARAttachment',
959
        widget=FileWidget(
960
            label=_("Attachment"),
961
            description=_("Add one or more attachments to describe the "
962
                          "sample in this sample, or to specify "
963
                          "your request."),
964
            render_own_label=True,
965
            visible={
966
                'view': 'invisible',
967
                'add': 'edit',
968
                'header_table': 'invisible',
969
            },
970
        )
971
    ),
972
973
    UIDReferenceField(
974
        'Invoice',
975
        allowed_types=('Invoice',),
976
        mode="rw",
977
        read_permission=View,
978
        write_permission=ModifyPortalContent,
979
        widget=ReferenceWidget(
980
            visible={
981
                'edit': 'invisible',
982
                'view': 'visible',
983
            },
984
        )
985
    ),
986
987
    DateTimeField(
988
        'DateReceived',
989
        mode="rw",
990
        read_permission=View,
991
        write_permission=FieldEditDateReceived,
992
        widget=DateTimeWidget(
993
            label=_("Date Sample Received"),
994
            show_time=True,
995
            datepicker_nofuture=1,
996
            description=_("The date when the sample was received"),
997
            render_own_label=True,
998
        ),
999
    ),
1000
    ComputedField(
1001
        'DatePublished',
1002
        mode="r",
1003
        read_permission=View,
1004
        expression="here.getDatePublished().strftime('%Y-%m-%d %H:%M %p') if here.getDatePublished() else ''",
1005
        widget=DateTimeWidget(
1006
            label=_("Date Published"),
1007
            visible={
1008
                'edit': 'invisible',
1009
                'add': 'invisible',
1010
                'secondary': 'invisible',
1011
            },
1012
        ),
1013
    ),
1014
1015
    RemarksField(
1016
        'Remarks',
1017
        read_permission=View,
1018
        write_permission=FieldEditRemarks,
1019
        widget=RemarksWidget(
1020
            label=_("Remarks"),
1021
            description=_("Remarks and comments for this request"),
1022
            render_own_label=True,
1023
            visible={
1024
                'add': 'edit',
1025
                'header_table': 'invisible',
1026
            },
1027
        ),
1028
    ),
1029
1030
    FixedPointField(
1031
        'MemberDiscount',
1032
        default_method='getDefaultMemberDiscount',
1033
        mode="rw",
1034
        read_permission=View,
1035
        write_permission=FieldEditMemberDiscount,
1036
        widget=DecimalWidget(
1037
            label=_("Member discount %"),
1038
            description=_("Enter percentage value eg. 33.0"),
1039
            render_own_label=True,
1040
            visible={
1041
                'add': 'invisible',
1042
            },
1043
        ),
1044
    ),
1045
1046
    ComputedField(
1047
        'SampleTypeTitle',
1048
        expression="here.getSampleType().Title() if here.getSampleType() "
1049
                   "else ''",
1050
        widget=ComputedWidget(
1051
            visible=False,
1052
        ),
1053
    ),
1054
1055
    ComputedField(
1056
        'SamplePointTitle',
1057
        expression="here.getSamplePoint().Title() if here.getSamplePoint() "
1058
                   "else ''",
1059
        widget=ComputedWidget(
1060
            visible=False,
1061
        ),
1062
    ),
1063
1064
    ComputedField(
1065
        'ContactUID',
1066
        expression="here.getContact() and here.getContact().UID() or ''",
1067
        widget=ComputedWidget(
1068
            visible=False,
1069
        ),
1070
    ),
1071
1072
    ComputedField(
1073
        'Invoiced',
1074
        expression='here.getInvoice() and True or False',
1075
        default=False,
1076
        widget=ComputedWidget(
1077
            visible=False,
1078
        ),
1079
    ),
1080
    ComputedField(
1081
        'ReceivedBy',
1082
        expression='here.getReceivedBy()',
1083
        default='',
1084
        widget=ComputedWidget(visible=False,),
1085
    ),
1086
    ComputedField(
1087
        'CreatorFullName',
1088
        expression="here._getCreatorFullName()",
1089
        widget=ComputedWidget(visible=False),
1090
    ),
1091
    ComputedField(
1092
        'CreatorEmail',
1093
        expression="here._getCreatorEmail()",
1094
        widget=ComputedWidget(visible=False),
1095
    ),
1096
    ComputedField(
1097
        'SamplerFullName',
1098
        expression="here._getSamplerFullName()",
1099
        widget=ComputedWidget(visible=False),
1100
    ),
1101
    ComputedField(
1102
        'SamplerEmail',
1103
        expression="here._getSamplerEmail()",
1104
        widget=ComputedWidget(visible=False),
1105
    ),
1106
    ComputedField(
1107
        'BatchID',
1108
        expression="here.getBatch().getId() if here.getBatch() else ''",
1109
        widget=ComputedWidget(visible=False),
1110
    ),
1111
    ComputedField(
1112
        'BatchURL',
1113
        expression="here.getBatch().absolute_url_path() " \
1114
                   "if here.getBatch() else ''",
1115
        widget=ComputedWidget(visible=False),
1116
    ),
1117
    ComputedField(
1118
        'ContactUsername',
1119
        expression="here.getContact().getUsername() " \
1120
                   "if here.getContact() else ''",
1121
        widget=ComputedWidget(visible=False),
1122
    ),
1123
    ComputedField(
1124
        'ContactFullName',
1125
        expression="here.getContact().getFullname() " \
1126
                   "if here.getContact() else ''",
1127
        widget=ComputedWidget(visible=False),
1128
    ),
1129
    ComputedField(
1130
        'ContactEmail',
1131
        expression="here.getContact().getEmailAddress() " \
1132
                   "if here.getContact() else ''",
1133
        widget=ComputedWidget(visible=False),
1134
    ),
1135
    ComputedField(
1136
        'SampleTypeUID',
1137
        expression="here.getSampleType().UID() " \
1138
                   "if here.getSampleType() else ''",
1139
        widget=ComputedWidget(visible=False),
1140
    ),
1141
    ComputedField(
1142
        'SamplePointUID',
1143
        expression="here.getSamplePoint().UID() " \
1144
                   "if here.getSamplePoint() else ''",
1145
        widget=ComputedWidget(visible=False),
1146
    ),
1147
    ComputedField(
1148
        'StorageLocationUID',
1149
        expression="here.getStorageLocation().UID() " \
1150
                   "if here.getStorageLocation() else ''",
1151
        widget=ComputedWidget(visible=False),
1152
    ),
1153
    ComputedField(
1154
        'TemplateUID',
1155
        expression="here.getTemplate().UID() if here.getTemplate() else ''",
1156
        widget=ComputedWidget(visible=False),
1157
    ),
1158
    ComputedField(
1159
        'TemplateURL',
1160
        expression="here.getTemplate().absolute_url_path() " \
1161
                   "if here.getTemplate() else ''",
1162
        widget=ComputedWidget(visible=False),
1163
    ),
1164
    ComputedField(
1165
        'TemplateTitle',
1166
        expression="here.getTemplate().Title() if here.getTemplate() else ''",
1167
        widget=ComputedWidget(visible=False),
1168
    ),
1169
1170
    UIDReferenceField(
1171
        'ParentAnalysisRequest',
1172
        allowed_types=('AnalysisRequest',),
1173
        relationship='AnalysisRequestParentAnalysisRequest',
1174
        mode="rw",
1175
        read_permission=View,
1176
        write_permission=ModifyPortalContent,
1177
        widget=ReferenceWidget(
1178
            visible=False,
1179
        ),
1180
    ),
1181
1182
    # The Primary Sample the current sample was detached from
1183
    UIDReferenceField(
1184
        "DetachedFrom",
1185
        allowed_types=("AnalysisRequest",),
1186
        mode="rw",
1187
        read_permission=View,
1188
        write_permission=ModifyPortalContent,
1189
        widget=ReferenceWidget(
1190
            visible=False,
1191
        )
1192
    ),
1193
1194
    # The Analysis Request the current Analysis Request comes from because of
1195
    # an invalidation of the former
1196
    UIDReferenceField(
1197
        'Invalidated',
1198
        allowed_types=('AnalysisRequest',),
1199
        relationship='AnalysisRequestRetracted',
1200
        mode="rw",
1201
        read_permission=View,
1202
        write_permission=ModifyPortalContent,
1203
        widget=ReferenceWidget(
1204
            visible=False,
1205
        ),
1206
    ),
1207
1208
    # For comments or results interpretation
1209
    # Old one, to be removed because of the incorporation of
1210
    # ResultsInterpretationDepts (due to LIMS-1628)
1211
    TextField(
1212
        'ResultsInterpretation',
1213
        mode="rw",
1214
        default_content_type='text/html',
1215
        # Input content type for the textfield
1216
        default_output_type='text/x-html-safe',
1217
        # getResultsInterpretation returns a str with html tags
1218
        # to conserve the txt format in the report.
1219
        read_permission=View,
1220
        write_permission=FieldEditResultsInterpretation,
1221
        widget=RichWidget(
1222
            description=_("Comments or results interpretation"),
1223
            label=_("Results Interpretation"),
1224
            size=10,
1225
            allow_file_upload=False,
1226
            default_mime_type='text/x-rst',
1227
            output_mime_type='text/x-html',
1228
            rows=3,
1229
            visible=False),
1230
    ),
1231
1232
    RecordsField(
1233
        'ResultsInterpretationDepts',
1234
        read_permission=View,
1235
        write_permission=FieldEditResultsInterpretation,
1236
        subfields=('uid', 'richtext'),
1237
        subfield_labels={
1238
            'uid': _('Department'),
1239
            'richtext': _('Results Interpretation')},
1240
        widget=RichWidget(visible=False),
1241
     ),
1242
    # Custom settings for the assigned analysis services
1243
    # https://jira.bikalabs.com/browse/LIMS-1324
1244
    # Fields:
1245
    #   - uid: Analysis Service UID
1246
    #   - hidden: True/False. Hide/Display in results reports
1247
    RecordsField('AnalysisServicesSettings',
1248
                 required=0,
1249
                 subfields=('uid', 'hidden',),
1250
                 widget=ComputedWidget(visible=False),
1251
                 ),
1252
    StringField(
1253
        'Printed',
1254
        mode="rw",
1255
        read_permission=View,
1256
        widget=StringWidget(
1257
            label=_("Printed"),
1258
            description=_("Indicates if the last SampleReport is printed,"),
1259
            visible=False,
1260
        ),
1261
    ),
1262
    BooleanField(
1263
        "InternalUse",
1264
        mode="rw",
1265
        required=0,
1266
        default=False,
1267
        read_permission=View,
1268
        write_permission=FieldEditInternalUse,
1269
        widget=BooleanWidget(
1270
            label=_("Internal use"),
1271
            description=_("Mark the sample for internal use only. This means "
1272
                          "it is only accessible to lab personnel and not to "
1273
                          "clients."),
1274
            format="radio",
1275
            render_own_label=True,
1276
            visible={'add': 'edit'}
1277
        ),
1278
    ),
1279
1280
    # Initial conditions for analyses set on Sample registration
1281
    RecordsField(
1282
        "ServiceConditions",
1283
        widget=ComputedWidget(visible=False)
1284
    ),
1285
1286
    # Number of samples to create on add form
1287
    IntegerField(
1288
        "NumSamples",
1289
        default=1,
1290
        widget=IntegerWidget(
1291
            label=_(
1292
                u"label_analysisrequest_numsamples",
1293
                default=u"Number of samples"
1294
            ),
1295
            description=_(
1296
                u"description_analysisrequest_numsamples",
1297
                default=u"Number of samples to create with the information "
1298
                        u"provided"),
1299
            # This field is only visible in add sample form
1300
            visible={
1301
                "add": "edit",
1302
                "view": "invisible",
1303
                "header_table": "invisible",
1304
                "secondary": "invisible",
1305
            },
1306
            render_own_label=True,
1307
        ),
1308
    ),
1309
)
1310
)
1311
1312
1313
# Some schema rearrangement
1314
schema['title'].required = False
1315
schema['id'].widget.visible = False
1316
schema['title'].widget.visible = False
1317
schema.moveField('Client', before='Contact')
1318
schema.moveField('ResultsInterpretation', pos='bottom')
1319
schema.moveField('ResultsInterpretationDepts', pos='bottom')
1320
schema.moveField("PrimaryAnalysisRequest", before="Client")
1321
1322
1323
class AnalysisRequest(BaseFolder, ClientAwareMixin):
1324
    implements(IAnalysisRequest, ICancellable)
1325
    security = ClassSecurityInfo()
1326
    displayContentsTab = False
1327
    schema = schema
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable schema does not seem to be defined.
Loading history...
1328
1329
    _at_rename_after_creation = True
1330
1331
    def _renameAfterCreation(self, check_auto_id=False):
1332
        """Rename hook called by processForm
1333
        """
1334
        # https://github.com/senaite/senaite.core/issues/1327
1335
        primary = self.getPrimaryAnalysisRequest()
1336
        if primary:
1337
            logger.info("Secondary sample detected: Skipping ID generation")
1338
            return False
1339
        from bika.lims.idserver import renameAfterCreation
1340
        renameAfterCreation(self)
1341
1342
    def _getCatalogTool(self):
1343
        from bika.lims.catalog import getCatalog
1344
        return getCatalog(self)
1345
1346
    def Title(self):
1347
        """ Return the Request ID as title """
1348
        return self.getId()
1349
1350
    def sortable_title(self):
1351
        """
1352
        Some lists expects this index
1353
        """
1354
        return self.getId()
1355
1356
    def Description(self):
1357
        """Returns searchable data as Description"""
1358
        descr = " ".join((self.getId(), self.aq_parent.Title()))
1359
        return safe_unicode(descr).encode('utf-8')
1360
1361
    def setSpecification(self, value):
1362
        """Sets the Specifications and ResultRange values
1363
        """
1364
        current_spec = self.getRawSpecification()
1365
        if value and current_spec == api.get_uid(value):
1366
            # Specification has not changed, preserve the current value to
1367
            # prevent result ranges (both from Sample and from analyses) from
1368
            # being overriden
1369
            return
1370
1371
        self.getField("Specification").set(self, value)
1372
1373
        # Set the value for field ResultsRange, cause Specification is only
1374
        # used as a template: all the results range logic relies on
1375
        # ResultsRange field, so changes in setup's Specification object won't
1376
        # have effect to already created samples
1377
        spec = self.getSpecification()
1378
        if spec:
1379
            # Update only results ranges if specs is not None, so results
1380
            # ranges manually set previously (e.g. via ManageAnalyses view) are
1381
            # preserved unless a new Specification overrides them
1382
            self.setResultsRange(spec.getResultsRange(), recursive=False)
1383
1384
        # Cascade the changes to partitions, but only to those that are in a
1385
        # status in which the specification can be updated. This prevents the
1386
        # re-assignment of Specifications to already verified or published
1387
        # samples
1388
        permission = self.getField("Specification").write_permission
1389
        for descendant in self.getDescendants():
1390
            if check_permission(permission, descendant):
1391
                descendant.setSpecification(spec)
1392
1393
    def setResultsRange(self, value, recursive=True):
1394
        """Sets the results range for this Sample and analyses it contains.
1395
        If recursive is True, then applies the results ranges to descendants
1396
        (partitions) as well as their analyses too
1397
        """
1398
        # Set Results Range to the Sample
1399
        field = self.getField("ResultsRange")
1400
        field.set(self, value)
1401
1402
        # Set Results Range to analyses
1403
        for analysis in self.objectValues("Analysis"):
1404
            if not ISubmitted.providedBy(analysis):
1405
                service_uid = analysis.getRawAnalysisService()
1406
                result_range = field.get(self, search_by=service_uid)
1407
                # check if we have an dynamic results range adapter
1408
                adapter = IDynamicResultsRange(analysis, None)
1409
                if adapter:
1410
                    # update the result range with the dynamic values
1411
                    result_range.update(adapter())
1412
                analysis.setResultsRange(result_range)
1413
                analysis.reindexObject()
1414
1415
        if recursive:
1416
            # Cascade the changes to partitions
1417
            permission = self.getField("Specification").write_permission
1418
            for descendant in self.getDescendants():
1419
                if check_permission(permission, descendant):
1420
                    descendant.setResultsRange(value)
1421
1422
    def setProfiles(self, value):
1423
        """Set Analysis Profiles to the Sample
1424
        """
1425
        if not isinstance(value, (list, tuple)):
1426
            value = [value]
1427
        # filter out empties
1428
        value = filter(None, value)
1429
        # ensure we have UIDs
1430
        uids = map(api.get_uid, value)
1431
        # get the current set profiles
1432
        current_profiles = self.getRawProfiles()
1433
        # return immediately if nothing changed
1434
        if current_profiles == uids:
1435
            return
1436
1437
        # Don't add analyses from profiles during sample creation.
1438
        # In this case the required analyses are added afterwards explicitly.
1439
        if not api.is_temporary(self):
1440
            # get the profiles
1441
            profiles = map(api.get_object_by_uid, uids)
1442
            # get the current set of analyses/services
1443
            analyses = self.getAnalyses(full_objects=True)
1444
            services = map(lambda an: an.getAnalysisService(), analyses)
1445
            # determine all the services to add
1446
            services_to_add = set(services)
1447
            for profile in profiles:
1448
                services_to_add.update(profile.getService())
1449
            # set all analyses
1450
            self.setAnalyses(list(services_to_add))
1451
1452
        # set the profiles value
1453
        self.getField("Profiles").set(self, value)
1454
1455
    def getClient(self):
1456
        """Returns the client this object is bound to. We override getClient
1457
        from ClientAwareMixin because the "Client" schema field is only used to
1458
        allow the user to set the client while creating the Sample through
1459
        Sample Add form, but cannot be changed afterwards. The Sample is
1460
        created directly inside the selected client folder on submit
1461
        """
1462
        if IClient.providedBy(self.aq_parent):
1463
            return self.aq_parent
1464
        if IBatch.providedBy(self.aq_parent):
1465
            return self.aq_parent.getClient()
1466
        return None
1467
1468
    @deprecated("Will be removed in SENAITE 3.0")
1469
    def getProfilesURL(self):
1470
        """Returns a list of all profile URLs
1471
1472
        Backwards compatibility for removed computed field:
1473
        https://github.com/senaite/senaite.core/pull/2213
1474
        """
1475
        return [profile.absolute_url_path() for profile in self.getProfiles()]
1476
1477
    @deprecated("Please use getRawProfiles instead. Will be removed in SENAITE 3.0")
1478
    def getProfilesUID(self):
1479
        """Returns a list of all profile UIDs
1480
1481
        Backwards compatibility for removed computed field:
1482
        https://github.com/senaite/senaite.core/pull/2213
1483
        """
1484
        return self.getRawProfiles()
1485
1486
    def getProfilesTitle(self):
1487
        """Returns a list of all profile titles
1488
1489
        Backwards compatibility for removed computed field:
1490
        https://github.com/senaite/senaite.core/pull/2213
1491
        """
1492
        return [profile.Title() for profile in self.getProfiles()]
1493
1494
    def getProfilesTitleStr(self, separator=", "):
1495
        """Returns a comma-separated string withg the titles of the profiles
1496
        assigned to this Sample. Used to populate a metadata field
1497
        """
1498
        return separator.join(self.getProfilesTitle())
1499
1500
    def getAnalysisService(self):
1501
        proxies = self.getAnalyses(full_objects=False)
1502
        value = set()
1503
        for proxy in proxies:
1504
            value.add(proxy.Title)
1505
        return list(value)
1506
1507
    def getAnalysts(self):
1508
        proxies = self.getAnalyses(full_objects=True)
1509
        value = []
1510
        for proxy in proxies:
1511
            val = proxy.getAnalyst()
1512
            if val not in value:
1513
                value.append(val)
1514
        return value
1515
1516
    def getDistrict(self):
1517
        client = self.aq_parent
1518
        return client.getDistrict()
1519
1520
    def getProvince(self):
1521
        client = self.aq_parent
1522
        return client.getProvince()
1523
1524
    @security.public
1525
    def getBatch(self):
1526
        # The parent type may be "Batch" during ar_add.
1527
        # This function fills the hidden field in ar_add.pt
1528
        if self.aq_parent.portal_type == 'Batch':
1529
            return self.aq_parent
1530
        else:
1531
            return self.Schema()['Batch'].get(self)
1532
1533
    @security.public
1534
    def getBatchUID(self):
1535
        batch = self.getBatch()
1536
        if batch:
1537
            return batch.UID()
1538
1539
    @security.public
1540
    def setBatch(self, value=None):
1541
        original_value = self.Schema().getField('Batch').get(self)
1542
        if original_value != value:
1543
            self.Schema().getField('Batch').set(self, value)
1544
1545
    def getDefaultMemberDiscount(self):
1546
        """Compute default member discount if it applies
1547
        """
1548
        if hasattr(self, 'getMemberDiscountApplies'):
1549
            if self.getMemberDiscountApplies():
1550
                settings = self.bika_setup
1551
                return settings.getMemberDiscount()
1552
            else:
1553
                return "0.00"
1554
1555
    @security.public
1556
    def getAnalysesNum(self):
1557
        """ Returns an array with the number of analyses for the current AR in
1558
            different statuses, like follows:
1559
                [verified, total, not_submitted, to_be_verified]
1560
        """
1561
        an_nums = [0, 0, 0, 0]
1562
        for analysis in self.getAnalyses():
1563
            review_state = analysis.review_state
1564
            if review_state in ['retracted', 'rejected', 'cancelled']:
1565
                continue
1566
            if review_state == 'to_be_verified':
1567
                an_nums[3] += 1
1568
            elif review_state in ['published', 'verified']:
1569
                an_nums[0] += 1
1570
            else:
1571
                an_nums[2] += 1
1572
            an_nums[1] += 1
1573
        return an_nums
1574
1575
    @security.public
1576
    def getResponsible(self):
1577
        """Return all manager info of responsible departments
1578
        """
1579
        managers = {}
1580
        for department in self.getDepartments():
1581
            manager = department.getManager()
1582
            if manager is None:
1583
                continue
1584
            manager_id = manager.getId()
1585
            if manager_id not in managers:
1586
                managers[manager_id] = {}
1587
                managers[manager_id]['salutation'] = safe_unicode(
1588
                    manager.getSalutation())
1589
                managers[manager_id]['name'] = safe_unicode(
1590
                    manager.getFullname())
1591
                managers[manager_id]['email'] = safe_unicode(
1592
                    manager.getEmailAddress())
1593
                managers[manager_id]['phone'] = safe_unicode(
1594
                    manager.getBusinessPhone())
1595
                managers[manager_id]['job_title'] = safe_unicode(
1596
                    manager.getJobTitle())
1597
                if manager.getSignature():
1598
                    managers[manager_id]['signature'] = \
1599
                        '{}/Signature'.format(manager.absolute_url())
1600
                else:
1601
                    managers[manager_id]['signature'] = False
1602
                managers[manager_id]['departments'] = ''
1603
            mngr_dept = managers[manager_id]['departments']
1604
            if mngr_dept:
1605
                mngr_dept += ', '
1606
            mngr_dept += safe_unicode(department.Title())
1607
            managers[manager_id]['departments'] = mngr_dept
1608
        mngr_keys = managers.keys()
1609
        mngr_info = {'ids': mngr_keys, 'dict': managers}
1610
1611
        return mngr_info
1612
1613
    @security.public
1614
    def getManagers(self):
1615
        """Return all managers of responsible departments
1616
        """
1617
        manager_ids = []
1618
        manager_list = []
1619
        for department in self.getDepartments():
1620
            manager = department.getManager()
1621
            if manager is None:
1622
                continue
1623
            manager_id = manager.getId()
1624
            if manager_id not in manager_ids:
1625
                manager_ids.append(manager_id)
1626
                manager_list.append(manager)
1627
        return manager_list
1628
1629
    def getDueDate(self):
1630
        """Returns the earliest due date of the analyses this Analysis Request
1631
        contains."""
1632
        due_dates = map(lambda an: an.getDueDate, self.getAnalyses())
1633
        return due_dates and min(due_dates) or None
1634
1635
    security.declareProtected(View, 'getLate')
1636
1637
    def getLate(self):
1638
        """Return True if there is at least one late analysis in this Request
1639
        """
1640
        for analysis in self.getAnalyses():
1641
            if analysis.review_state == "retracted":
1642
                continue
1643
            analysis_obj = api.get_object(analysis)
1644
            if analysis_obj.isLateAnalysis():
1645
                return True
1646
        return False
1647
1648
    def getRawReports(self):
1649
        """Returns UIDs of reports with a reference to this sample
1650
1651
        see: ARReport.ContainedAnalysisRequests field
1652
1653
        :returns: List of report UIDs
1654
        """
1655
        return get_backreferences(self, "ARReportAnalysisRequest")
1656
1657
    def getReports(self):
1658
        """Returns a list of report objects
1659
1660
        :returns: List of report objects
1661
        """
1662
        return list(map(api.get_object, self.getRawReports()))
1663
1664
    def getPrinted(self):
1665
        """ returns "0", "1" or "2" to indicate Printed state.
1666
            0 -> Never printed.
1667
            1 -> Printed after last publish
1668
            2 -> Printed but republished afterwards.
1669
        """
1670
        if not self.getDatePublished():
1671
            return "0"
1672
1673
        report_uids = self.getRawReports()
1674
        if not report_uids:
1675
            return "0"
1676
1677
        last_report = api.get_object(report_uids[-1])
1678
        if last_report.getDatePrinted():
1679
            return "1"
1680
1681
        for report_uid in report_uids[:-1]:
1682
            report = api.get_object(report_uid)
1683
            if report.getDatePrinted():
1684
                return "2"
1685
1686
        return "0"
1687
1688
    @security.protected(View)
1689
    def getBillableItems(self):
1690
        """Returns the items to be billed
1691
        """
1692
        # Assigned profiles
1693
        profiles = self.getProfiles()
1694
        # Billable profiles which have a fixed price set
1695
        billable_profiles = filter(
1696
            lambda pr: pr.getUseAnalysisProfilePrice(), profiles)
1697
        # All services contained in the billable profiles
1698
        billable_profile_services = functools.reduce(lambda a, b: a+b, map(
1699
            lambda profile: profile.getService(), billable_profiles), [])
1700
        # Keywords of the contained services
1701
        billable_service_keys = map(
1702
            lambda s: s.getKeyword(), set(billable_profile_services))
1703
        # Billable items contain billable profiles and single selected analyses
1704
        billable_items = billable_profiles
1705
        # Get the analyses to be billed
1706
        exclude_rs = ["retracted", "rejected"]
1707
        for analysis in self.getAnalyses(is_active=True):
1708
            if analysis.review_state in exclude_rs:
1709
                continue
1710
            if analysis.getKeyword in billable_service_keys:
1711
                continue
1712
            billable_items.append(api.get_object(analysis))
1713
        return billable_items
1714
1715
    @security.protected(View)
1716
    def getSubtotal(self):
1717
        """Compute Subtotal (without member discount and without vat)
1718
        """
1719
        return sum([Decimal(obj.getPrice()) for obj in self.getBillableItems()])
1720
1721
    @security.protected(View)
1722
    def getSubtotalVATAmount(self):
1723
        """Compute VAT amount without member discount
1724
        """
1725
        return sum([Decimal(o.getVATAmount()) for o in self.getBillableItems()])
1726
1727
    @security.protected(View)
1728
    def getSubtotalTotalPrice(self):
1729
        """Compute the price with VAT but no member discount
1730
        """
1731
        return self.getSubtotal() + self.getSubtotalVATAmount()
1732
1733
    @security.protected(View)
1734
    def getDiscountAmount(self):
1735
        """It computes and returns the analysis service's discount amount
1736
        without VAT
1737
        """
1738
        has_client_discount = self.aq_parent.getMemberDiscountApplies()
1739
        if has_client_discount:
1740
            discount = Decimal(self.getDefaultMemberDiscount())
1741
            return Decimal(self.getSubtotal() * discount / 100)
1742
        else:
1743
            return 0
1744
1745
    @security.protected(View)
1746
    def getVATAmount(self):
1747
        """It computes the VAT amount from (subtotal-discount.)*VAT/100, but
1748
        each analysis has its own VAT!
1749
1750
        :returns: the analysis request VAT amount with the discount
1751
        """
1752
        has_client_discount = self.aq_parent.getMemberDiscountApplies()
1753
        VATAmount = self.getSubtotalVATAmount()
1754
        if has_client_discount:
1755
            discount = Decimal(self.getDefaultMemberDiscount())
1756
            return Decimal((1 - discount / 100) * VATAmount)
1757
        else:
1758
            return VATAmount
1759
1760
    @security.protected(View)
1761
    def getTotalPrice(self):
1762
        """It gets the discounted price from analyses and profiles to obtain the
1763
        total value with the VAT and the discount applied
1764
1765
        :returns: analysis request's total price including VATs and discounts
1766
        """
1767
        price = (self.getSubtotal() - self.getDiscountAmount() +
1768
                 self.getVATAmount())
1769
        return price
1770
1771
    getTotal = getTotalPrice
1772
1773
    @security.protected(ManageInvoices)
1774
    def createInvoice(self, pdf):
1775
        """Issue invoice
1776
        """
1777
        client = self.getClient()
1778
        invoice = self.getInvoice()
1779
        if not invoice:
1780
            invoice = _createObjectByType("Invoice", client, tmpID())
1781
        invoice.edit(
1782
            AnalysisRequest=self,
1783
            Client=client,
1784
            InvoiceDate=DateTime(),
1785
            InvoicePDF=pdf
1786
        )
1787
        invoice.processForm()
1788
        self.setInvoice(invoice)
1789
        return invoice
1790
1791
    @security.public
1792
    def printInvoice(self, REQUEST=None, RESPONSE=None):
1793
        """Print invoice
1794
        """
1795
        invoice = self.getInvoice()
1796
        invoice_url = invoice.absolute_url()
1797
        RESPONSE.redirect('{}/invoice_print'.format(invoice_url))
1798
1799
    @deprecated("Use getVerifiers instead. Will be removed in SENAITE 3.0")
1800
    @security.public
1801
    def getVerifier(self):
1802
        """Returns the user that verified the whole Analysis Request. Since the
1803
        verification is done automatically as soon as all the analyses it
1804
        contains are verified, this function returns the user that verified the
1805
        last analysis pending.
1806
        """
1807
        wtool = getToolByName(self, 'portal_workflow')
1808
        mtool = getToolByName(self, 'portal_membership')
1809
1810
        verifier = None
1811
        try:
1812
            review_history = wtool.getInfoFor(self, 'review_history')
1813
        except Exception:
1814
            return 'access denied'
1815
1816
        if not review_history:
1817
            return 'no history'
1818
        for items in review_history:
1819
            action = items.get('action')
1820
            if action != 'verify':
1821
                continue
1822
            actor = items.get('actor')
1823
            member = mtool.getMemberById(actor)
1824
            verifier = member.getProperty('fullname')
1825
            if verifier is None or verifier == '':
1826
                verifier = actor
1827
        return verifier
1828
1829
    @security.public
1830
    def getVerifiersIDs(self):
1831
        """Returns the ids from users that have verified at least one analysis
1832
        from this Analysis Request
1833
        """
1834
        verifiers_ids = list()
1835
        for brain in self.getAnalyses():
1836
            verifiers_ids += brain.getVerificators
1837
        return list(set(verifiers_ids))
1838
1839
    @security.public
1840
    def getVerifiers(self):
1841
        """Returns the list of lab contacts that have verified at least one
1842
        analysis from this Analysis Request
1843
        """
1844
        contacts = list()
1845
        for verifier in self.getVerifiersIDs():
1846
            user = api.get_user(verifier)
1847
            contact = api.get_user_contact(user, ["LabContact"])
1848
            if contact:
1849
                contacts.append(contact)
1850
        return contacts
1851
1852
    security.declarePublic('current_date')
1853
1854
    def current_date(self):
1855
        """return current date
1856
        """
1857
        # noinspection PyCallingNonCallable
1858
        return DateTime()
1859
1860
    def getWorksheets(self, full_objects=False):
1861
        """Returns the worksheets that contains analyses from this Sample
1862
        """
1863
        # Get the Analyses UIDs of this Sample
1864
        analyses_uids = map(api.get_uid, self.getAnalyses())
1865
        if not analyses_uids:
1866
            return []
1867
1868
        # Get the worksheets that contain any of these analyses
1869
        query = dict(getAnalysesUIDs=analyses_uids)
1870
        worksheets = api.search(query, WORKSHEET_CATALOG)
1871
        if full_objects:
1872
            worksheets = map(api.get_object, worksheets)
1873
        return worksheets
1874
1875
    def getQCAnalyses(self, review_state=None):
1876
        """Returns the Quality Control analyses assigned to worksheets that
1877
        contains analyses from this Sample
1878
        """
1879
        # Get the worksheet uids
1880
        worksheet_uids = map(api.get_uid, self.getWorksheets())
1881
        if not worksheet_uids:
1882
            return []
1883
1884
        # Get reference qc analyses from these worksheets
1885
        query = dict(portal_type="ReferenceAnalysis",
1886
                     getWorksheetUID=worksheet_uids)
1887
        qc_analyses = api.search(query, ANALYSIS_CATALOG)
1888
1889
        # Extend with duplicate qc analyses from these worksheets and Sample
1890
        query = dict(portal_type="DuplicateAnalysis",
1891
                     getWorksheetUID=worksheet_uids,
1892
                     getAncestorsUIDs=[api.get_uid(self)])
1893
        qc_analyses += api.search(query, ANALYSIS_CATALOG)
1894
1895
        # Bail out analyses with a different review_state
1896
        if review_state:
1897
            qc_analyses = filter(
1898
                lambda an: api.get_review_status(an) in review_state,
1899
                qc_analyses
1900
            )
1901
1902
        # Return the objects
1903
        return map(api.get_object, qc_analyses)
1904
1905
    def isInvalid(self):
1906
        """return if the Analysis Request has been invalidated
1907
        """
1908
        workflow = getToolByName(self, 'portal_workflow')
1909
        return workflow.getInfoFor(self, 'review_state') == 'invalid'
1910
1911
    def getStorageLocationTitle(self):
1912
        """ A method for AR listing catalog metadata
1913
        :return: Title of Storage Location
1914
        """
1915
        sl = self.getStorageLocation()
1916
        if sl:
1917
            return sl.Title()
1918
        return ''
1919
1920
    def getDatePublished(self):
1921
        """
1922
        Returns the transition date from the Analysis Request object
1923
        """
1924
        return getTransitionDate(self, 'publish', return_as_datetime=True)
1925
1926
    @security.public
1927
    def getSamplingDeviationTitle(self):
1928
        """
1929
        It works as a metacolumn.
1930
        """
1931
        sd = self.getSamplingDeviation()
1932
        if sd:
1933
            return sd.Title()
1934
        return ''
1935
1936
    @security.public
1937
    def getSampleConditionTitle(self):
1938
        """Helper method to access the title of the sample condition
1939
        """
1940
        obj = self.getSampleCondition()
1941
        if not obj:
1942
            return ""
1943
        return api.get_title(obj)
1944
1945
    @security.public
1946
    def getHazardous(self):
1947
        """
1948
        It works as a metacolumn.
1949
        """
1950
        sample_type = self.getSampleType()
1951
        if sample_type:
1952
            return sample_type.getHazardous()
1953
        return False
1954
1955
    @security.public
1956
    def getContactURL(self):
1957
        """
1958
        It works as a metacolumn.
1959
        """
1960
        contact = self.getContact()
1961
        if contact:
1962
            return contact.absolute_url_path()
1963
        return ''
1964
1965
    @security.public
1966
    def getSamplingWorkflowEnabled(self):
1967
        """Returns True if the sample of this Analysis Request has to be
1968
        collected by the laboratory personnel
1969
        """
1970
        template = self.getTemplate()
1971
        if template:
1972
            return template.getSamplingRequired()
1973
        return self.bika_setup.getSamplingWorkflowEnabled()
1974
1975
    def getSamplers(self):
1976
        return getUsers(self, ['Sampler', ])
1977
1978
    def getPreservers(self):
1979
        return getUsers(self, ['Preserver', 'Sampler'])
1980
1981
    def getDepartments(self):
1982
        """Returns a list of the departments assigned to the Analyses
1983
        from this Analysis Request
1984
        """
1985
        departments = list()
1986
        for analysis in self.getAnalyses(full_objects=True):
1987
            department = analysis.getDepartment()
1988
            if department and department not in departments:
1989
                departments.append(department)
1990
        return departments
1991
1992
    def getResultsInterpretationByDepartment(self, department=None):
1993
        """Returns the results interpretation for this Analysis Request
1994
           and department. If department not set, returns the results
1995
           interpretation tagged as 'General'.
1996
1997
        :returns: a dict with the following keys:
1998
            {'uid': <department_uid> or 'general', 'richtext': <text/plain>}
1999
        """
2000
        uid = department.UID() if department else 'general'
2001
        rows = self.Schema()['ResultsInterpretationDepts'].get(self)
2002
        row = [row for row in rows if row.get('uid') == uid]
2003
        if len(row) > 0:
2004
            row = row[0]
2005
        elif uid == 'general' \
2006
                and hasattr(self, 'getResultsInterpretation') \
2007
                and self.getResultsInterpretation():
2008
            row = {'uid': uid, 'richtext': self.getResultsInterpretation()}
2009
        else:
2010
            row = {'uid': uid, 'richtext': ''}
2011
        return row
2012
2013
    def getAnalysisServiceSettings(self, uid):
2014
        """Returns a dictionary with the settings for the analysis service that
2015
        match with the uid provided.
2016
2017
        If there are no settings for the analysis service and
2018
        analysis requests:
2019
2020
        1. looks for settings in AR's ARTemplate. If found, returns the
2021
           settings for the AnalysisService set in the Template
2022
        2. If no settings found, looks in AR's ARProfile. If found, returns the
2023
           settings for the AnalysisService from the AR Profile. Otherwise,
2024
           returns a one entry dictionary with only the key 'uid'
2025
        """
2026
        sets = [s for s in self.getAnalysisServicesSettings()
2027
                if s.get('uid', '') == uid]
2028
2029
        # Created by using an ARTemplate?
2030
        if not sets and self.getTemplate():
2031
            adv = self.getTemplate().getAnalysisServiceSettings(uid)
2032
            sets = [adv] if 'hidden' in adv else []
2033
2034
        # Created by using an AR Profile?
2035
        profiles = self.getProfiles()
2036
        if not sets and profiles:
2037
            adv = [profile.getAnalysisServiceSettings(uid) for profile in
2038
                   profiles]
2039
            sets = adv if 'hidden' in adv[0] else []
2040
2041
        return sets[0] if sets else {'uid': uid}
2042
2043
    # TODO Sample Cleanup - Remove (Use getContainer instead)
2044
    def getContainers(self):
2045
        """This functions returns the containers from the analysis request's
2046
        analyses
2047
2048
        :returns: a list with the full partition objects
2049
        """
2050
        return self.getContainer() and [self.getContainer] or []
2051
2052 View Code Duplication
    def isAnalysisServiceHidden(self, uid):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
2053
        """Checks if the analysis service that match with the uid provided must
2054
        be hidden in results. If no hidden assignment has been set for the
2055
        analysis in this request, returns the visibility set to the analysis
2056
        itself.
2057
2058
        Raise a TypeError if the uid is empty or None
2059
2060
        Raise a ValueError if there is no hidden assignment in this request or
2061
        no analysis service found for this uid.
2062
        """
2063
        if not uid:
2064
            raise TypeError('None type or empty uid')
2065
        sets = self.getAnalysisServiceSettings(uid)
2066
        if 'hidden' not in sets:
2067
            uc = getToolByName(self, 'uid_catalog')
2068
            serv = uc(UID=uid)
2069
            if serv and len(serv) == 1:
2070
                return serv[0].getObject().getRawHidden()
2071
            else:
2072
                raise ValueError('{} is not valid'.format(uid))
2073
        return sets.get('hidden', False)
2074
2075
    def getRejecter(self):
2076
        """If the Analysis Request has been rejected, returns the user who did the
2077
        rejection. If it was not rejected or the current user has not enough
2078
        privileges to access to this information, returns None.
2079
        """
2080
        wtool = getToolByName(self, 'portal_workflow')
2081
        mtool = getToolByName(self, 'portal_membership')
2082
        try:
2083
            review_history = wtool.getInfoFor(self, 'review_history')
2084
        except Exception:
2085
            return None
2086
        for items in review_history:
2087
            action = items.get('action')
2088
            if action != 'reject':
2089
                continue
2090
            actor = items.get('actor')
2091
            return mtool.getMemberById(actor)
2092
        return None
2093
2094
    def getReceivedBy(self):
2095
        """
2096
        Returns the User who received the analysis request.
2097
        :returns: the user id
2098
        """
2099
        user = getTransitionUsers(self, 'receive', last_user=True)
2100
        return user[0] if user else ''
2101
2102
    def getDateVerified(self):
2103
        """
2104
        Returns the date of verification as a DateTime object.
2105
        """
2106
        return getTransitionDate(self, 'verify', return_as_datetime=True)
2107
2108
    @security.public
2109
    def getPrioritySortkey(self):
2110
        """Returns the key that will be used to sort the current Analysis
2111
        Request based on both its priority and creation date. On ASC sorting,
2112
        the oldest item with highest priority will be displayed.
2113
        :return: string used for sorting
2114
        """
2115
        priority = self.getPriority()
2116
        created_date = self.created().ISO8601()
2117
        return '%s.%s' % (priority, created_date)
2118
2119
    @security.public
2120
    def setPriority(self, value):
2121
        if not value:
2122
            value = self.Schema().getField('Priority').getDefault(self)
2123
        original_value = self.Schema().getField('Priority').get(self)
2124
        if original_value != value:
2125
            self.Schema().getField('Priority').set(self, value)
2126
            self._reindexAnalyses(['getPrioritySortkey'], True)
2127
2128
    @security.private
2129
    def _reindexAnalyses(self, idxs=None, update_metadata=False):
2130
        if not idxs and not update_metadata:
2131
            return
2132
        if not idxs:
2133
            idxs = []
2134
        analyses = self.getAnalyses()
2135
        catalog = getToolByName(self, ANALYSIS_CATALOG)
2136
        for analysis in analyses:
2137
            analysis_obj = analysis.getObject()
2138
            catalog.reindexObject(analysis_obj, idxs=idxs, update_metadata=1)
2139
2140
    def _getCreatorFullName(self):
2141
        """
2142
        Returns the full name of this analysis request's creator.
2143
        """
2144
        return user_fullname(self, self.Creator())
2145
2146
    def _getCreatorEmail(self):
2147
        """
2148
        Returns the email of this analysis request's creator.
2149
        """
2150
        return user_email(self, self.Creator())
2151
2152
    def _getSamplerFullName(self):
2153
        """
2154
        Returns the full name's defined sampler.
2155
        """
2156
        return user_fullname(self, self.getSampler())
2157
2158
    def _getSamplerEmail(self):
2159
        """
2160
        Returns the email of this analysis request's sampler.
2161
        """
2162
        return user_email(self, self.getSampler())
2163
2164
    def getPriorityText(self):
2165
        """
2166
        This function looks up the priority text from priorities vocab
2167
        :returns: the priority text or ''
2168
        """
2169
        if self.getPriority():
2170
            return PRIORITIES.getValue(self.getPriority())
2171
        return ''
2172
2173
    def get_ARAttachment(self):
2174
        return None
2175
2176
    def set_ARAttachment(self, value):
2177
        return None
2178
2179
    def getRawRetest(self):
2180
        """Returns the UID of the Analysis Request that has been generated
2181
        automatically because of the retraction of the current Analysis Request
2182
        """
2183
        relationship = self.getField("Invalidated").relationship
2184
        uids = get_backreferences(self, relationship=relationship)
2185
        return uids[0] if uids else None
2186
2187
    def getRetest(self):
2188
        """Returns the Analysis Request that has been generated automatically
2189
        because of the retraction of the current Analysis Request
2190
        """
2191
        uid = self.getRawRetest()
2192
        return api.get_object_by_uid(uid, default=None)
2193
2194
    def getAncestors(self, all_ancestors=True):
2195
        """Returns the ancestor(s) of this Analysis Request
2196
        param all_ancestors: include all ancestors, not only the parent
2197
        """
2198
        parent = self.getParentAnalysisRequest()
2199
        if not parent:
2200
            return list()
2201
        if not all_ancestors:
2202
            return [parent]
2203
        return [parent] + parent.getAncestors(all_ancestors=True)
2204
2205
    def isRootAncestor(self):
2206
        """Returns True if the AR is the root ancestor
2207
2208
        :returns: True if the AR has no more parents
2209
        """
2210
        parent = self.getParentAnalysisRequest()
2211
        if parent:
2212
            return False
2213
        return True
2214
2215
    def getDescendants(self, all_descendants=False):
2216
        """Returns the descendant Analysis Requests
2217
2218
        :param all_descendants: recursively include all descendants
2219
        """
2220
2221
        uids = self.getDescendantsUIDs()
2222
        if not uids:
2223
            return []
2224
2225
        # Extract the descendant objects
2226
        descendants = []
2227
        cat = api.get_tool(UID_CATALOG)
2228
        for brain in cat(UID=uids):
2229
            descendant = api.get_object(brain)
2230
            descendants.append(descendant)
2231
            if all_descendants:
2232
                # Extend with grandchildren
2233
                descendants += descendant.getDescendants(all_descendants=True)
2234
2235
        return descendants
2236
2237
    def getDescendantsUIDs(self):
2238
        """Returns the UIDs of the descendant Analysis Requests
2239
2240
        This method is used as metadata
2241
        """
2242
        relationship = self.getField("ParentAnalysisRequest").relationship
2243
        return get_backreferences(self, relationship=relationship)
2244
2245
    def isPartition(self):
2246
        """Returns true if this Analysis Request is a partition
2247
        """
2248
        return not self.isRootAncestor()
2249
2250
    # TODO Remove in favour of getSamplingWorkflowEnabled
2251
    def getSamplingRequired(self):
2252
        """Returns True if the sample of this Analysis Request has to be
2253
        collected by the laboratory personnel
2254
        """
2255
        return self.getSamplingWorkflowEnabled()
2256
2257
    def isOpen(self):
2258
        """Returns whether all analyses from this Analysis Request haven't been
2259
        submitted yet (are in a open status)
2260
        """
2261
        for analysis in self.getAnalyses():
2262
            if ISubmitted.providedBy(api.get_object(analysis)):
2263
                return False
2264
        return True
2265
2266
    def setParentAnalysisRequest(self, value):
2267
        """Sets a parent analysis request, making the current a partition
2268
        """
2269
        parent = self.getParentAnalysisRequest()
2270
        self.Schema().getField("ParentAnalysisRequest").set(self, value)
2271
        if not value:
2272
            noLongerProvides(self, IAnalysisRequestPartition)
2273
            if parent and not parent.getDescendants(all_descendants=False):
2274
                noLongerProvides(self, IAnalysisRequestWithPartitions)
2275
        else:
2276
            alsoProvides(self, IAnalysisRequestPartition)
2277
            parent = self.getParentAnalysisRequest()
2278
            alsoProvides(parent, IAnalysisRequestWithPartitions)
2279
2280
    def getRawSecondaryAnalysisRequests(self):
2281
        """Returns the UIDs of the secondary Analysis Requests from this
2282
        Analysis Request
2283
        """
2284
        relationship = self.getField("PrimaryAnalysisRequest").relationship
2285
        return get_backreferences(self, relationship)
2286
2287
    def getSecondaryAnalysisRequests(self):
2288
        """Returns the secondary analysis requests from this analysis request
2289
        """
2290
        uids = self.getRawSecondaryAnalysisRequests()
2291
        uc = api.get_tool("uid_catalog")
2292
        return [api.get_object(brain) for brain in uc(UID=uids)]
2293
2294
    def setDateReceived(self, value):
2295
        """Sets the date received to this analysis request and to secondary
2296
        analysis requests
2297
        """
2298
        self.Schema().getField('DateReceived').set(self, value)
2299
        for secondary in self.getSecondaryAnalysisRequests():
2300
            secondary.setDateReceived(value)
2301
            secondary.reindexObject(idxs=["getDateReceived", "is_received"])
2302
2303
    def setDateSampled(self, value):
2304
        """Sets the date sampled to this analysis request and to secondary
2305
        analysis requests
2306
        """
2307
        self.Schema().getField('DateSampled').set(self, value)
2308
        for secondary in self.getSecondaryAnalysisRequests():
2309
            secondary.setDateSampled(value)
2310
            secondary.reindexObject(idxs="getDateSampled")
2311
2312
    def setSamplingDate(self, value):
2313
        """Sets the sampling date to this analysis request and to secondary
2314
        analysis requests
2315
        """
2316
        self.Schema().getField('SamplingDate').set(self, value)
2317
        for secondary in self.getSecondaryAnalysisRequests():
2318
            secondary.setSamplingDate(value)
2319
            secondary.reindexObject(idxs="getSamplingDate")
2320
2321
    def getSelectedRejectionReasons(self):
2322
        """Returns a list with the selected rejection reasons, if any
2323
        """
2324
        reasons = self.getRejectionReasons()
2325
        if not reasons:
2326
            return []
2327
2328
        # Return a copy of the list to avoid accidental writes
2329
        reasons = reasons[0].get("selected", [])[:]
2330
        return filter(None, reasons)
2331
2332
    def getOtherRejectionReasons(self):
2333
        """Returns other rejection reasons custom text, if any
2334
        """
2335
        reasons = self.getRejectionReasons()
2336
        if not reasons:
2337
            return ""
2338
        return reasons[0].get("other", "").strip()
2339
2340
    def createAttachment(self, filedata, filename="", **kw):
2341
        """Add a new attachment to the sample
2342
2343
        :param filedata: Raw filedata of the attachment (not base64)
2344
        :param filename: Filename + extension, e.g. `image.png`
2345
        :param kw: Additional keywords set to the attachment
2346
        :returns: New created and added attachment
2347
        """
2348
        # Add a new Attachment
2349
        attachment = api.create(self.getClient(), "Attachment")
2350
        attachment.setAttachmentFile(filedata)
2351
        fileobj = attachment.getAttachmentFile()
2352
        fileobj.filename = filename
2353
        attachment.edit(**kw)
2354
        attachment.processForm()
2355
        self.addAttachment(attachment)
2356
        return attachment
2357
2358
    def addAttachment(self, attachment):
2359
        """Adds an attachment or a list of attachments to the Analysis Request
2360
        """
2361
        if not isinstance(attachment, (list, tuple)):
2362
            attachment = [attachment]
2363
2364
        original = self.getAttachment() or []
2365
2366
        # Function addAttachment can accept brain, objects or uids
2367
        original = map(api.get_uid, original)
2368
        attachment = map(api.get_uid, attachment)
2369
2370
        # Boil out attachments already assigned to this Analysis Request
2371
        attachment = filter(lambda at: at not in original, attachment)
2372
        if attachment:
2373
            original.extend(attachment)
2374
            self.setAttachment(original)
2375
2376
    def setResultsInterpretationDepts(self, value):
2377
        """Custom setter which converts inline images to attachments
2378
2379
        https://github.com/senaite/senaite.core/pull/1344
2380
2381
        :param value: list of dictionary records
2382
        """
2383
        if not isinstance(value, list):
2384
            raise TypeError("Expected list, got {}".format(type(value)))
2385
2386
        # Convert inline images -> attachment files
2387
        records = []
2388
        for record in value:
2389
            # N.B. we might here a ZPublisher record. Converting to dict
2390
            #      ensures we can set values as well.
2391
            record = dict(record)
2392
            # Handle inline images in the HTML
2393
            html = record.get("richtext", "")
2394
            # Process inline images to attachments
2395
            record["richtext"] = self.process_inline_images(html)
2396
            # append the processed record for storage
2397
            records.append(record)
2398
2399
        # set the field
2400
        self.getField("ResultsInterpretationDepts").set(self, records)
2401
2402
    def process_inline_images(self, html):
2403
        """Convert inline images in the HTML to attachments
2404
2405
        https://github.com/senaite/senaite.core/pull/1344
2406
2407
        :param html: The richtext HTML
2408
        :returns: HTML with converted images
2409
        """
2410
        # Check for inline images
2411
        inline_images = re.findall(IMG_DATA_SRC_RX, html)
2412
2413
        # convert to inline images -> attachments
2414
        for data_type, data in inline_images:
2415
            # decode the base64 data to filedata
2416
            filedata = base64.decodestring(data)
2417
            # extract the file extension from the data type
2418
            extension = data_type.lstrip("data:image/").rstrip(";base64,")
2419
            # generate filename + extension
2420
            filename = "attachment.{}".format(extension or "png")
2421
            # create a new attachment
2422
            attachment = self.createAttachment(filedata, filename)
2423
            # ignore the attachment in report
2424
            attachment.setRenderInReport(False)
2425
            # remove the image data base64 prefix
2426
            html = html.replace(data_type, "")
2427
            # remove the base64 image data with the attachment link
2428
            html = html.replace(data, "resolve_attachment?uid={}".format(
2429
                api.get_uid(attachment)))
2430
            size = attachment.getAttachmentFile().get_size()
2431
            logger.info("Converted {:.2f} Kb inline image for {}"
2432
                        .format(size/1024, api.get_url(self)))
2433
2434
        # convert relative URLs to absolute URLs
2435
        # N.B. This is actually a TinyMCE issue, but hardcoded in Plone:
2436
        #  https://www.tiny.cloud/docs/configure/url-handling/#relative_urls
2437
        image_sources = re.findall(IMG_SRC_RX, html)
2438
2439
        # add a trailing slash so that urljoin doesn't remove the last segment
2440
        base_url = "{}/".format(api.get_url(self))
2441
2442
        for src in image_sources:
2443
            if re.match("(http|https|data)", src):
2444
                continue
2445
            obj = self.restrictedTraverse(src, None)
2446
            if obj is None:
2447
                continue
2448
            # ensure we have an absolute URL
2449
            html = html.replace(src, urljoin(base_url, src))
2450
2451
        return html
2452
2453
    def getProgress(self):
2454
        """Returns the progress in percent of all analyses
2455
        """
2456
        review_state = api.get_review_status(self)
2457
2458
        # Consider final states as 100%
2459
        # https://github.com/senaite/senaite.core/pull/1544#discussion_r379821841
2460
        if review_state in FINAL_STATES:
2461
            return 100
2462
2463
        numbers = self.getAnalysesNum()
2464
2465
        num_analyses = numbers[1] or 0
2466
        if not num_analyses:
2467
            return 0
2468
2469
        # [verified, total, not_submitted, to_be_verified]
2470
        num_to_be_verified = numbers[3] or 0
2471
        num_verified = numbers[0] or 0
2472
2473
        # 2 steps per analysis (submit, verify) plus one step for publish
2474
        max_num_steps = (num_analyses * 2) + 1
2475
        num_steps = num_to_be_verified + (num_verified * 2)
2476
        if not num_steps:
2477
            return 0
2478
        if num_steps > max_num_steps:
2479
            return 100
2480
        return (num_steps * 100) / max_num_steps
2481
2482
2483
registerType(AnalysisRequest, PROJECTNAME)
2484