Passed
Push — 2.x ( 581f55...9e83c7 )
by Ramon
06:07
created

AnalysisRequest._getSamplerFullName()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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