AnalysisRequest.getProfilesTitle()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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