Passed
Push — 2.x ( d59516...b5e2f5 )
by Jordi
06:37
created

il()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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