Passed
Push — 2.x ( 7ab1ba...eed380 )
by Ramon
08:13
created

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