AnalysisRequest.getProgress()   A
last analyzed

Complexity

Conditions 5

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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