Passed
Push — 2.x ( ce92dc...cee671 )
by Jordi
07:08 queued 01:32
created

AnalysisRequest.getProfilesTitle()   A

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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