Passed
Push — 2.x ( 488bc1...eec905 )
by Ramon
06:12
created

AnalysisRequest.get_profiles_query()   A

Complexity

Conditions 1

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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