Passed
Push — 2.x ( bd408b...df866e )
by Jordi
06:03
created

AnalysisRequest.getRawRetest()   A

Complexity

Conditions 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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