bika.lims.content.analysisrequest   F
last analyzed

Complexity

Total Complexity 236

Size/Duplication

Total Lines 2634
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 236
eloc 1751
dl 0
loc 2634
rs 0.8
c 0
b 0
f 0

92 Methods

Rating   Name   Duplication   Size   Complexity  
A AnalysisRequest.getAnalysisService() 0 6 2
A AnalysisRequest.getDueDate() 0 5 2
A AnalysisRequest.getClient() 0 15 3
A AnalysisRequest.getProfilesTitle() 0 7 1
A AnalysisRequest.setBatch() 0 5 2
A AnalysisRequest.getBatch() 0 8 2
A AnalysisRequest.getAnalysesNum() 0 19 5
A AnalysisRequest.getAnalysts() 0 8 3
A AnalysisRequest.getManagers() 0 15 4
A AnalysisRequest.getProfilesUID() 0 8 1
A AnalysisRequest.getDefaultMemberDiscount() 0 9 3
A AnalysisRequest.getLate() 0 10 4
A AnalysisRequest.getProfilesURL() 0 8 1
A AnalysisRequest.getProfilesTitleStr() 0 5 1
A AnalysisRequest.getDistrict() 0 3 1
A AnalysisRequest.getBatchUID() 0 5 2
B AnalysisRequest.getResponsible() 0 37 6
A AnalysisRequest.getProvince() 0 3 1
A AnalysisRequest._getCatalogTool() 0 3 1
B AnalysisRequest.getBillableItems() 0 26 8
A AnalysisRequest._reindexAnalyses() 0 11 5
A AnalysisRequest.getSamplingWorkflowEnabled() 0 9 2
A AnalysisRequest.get_sample_points_query() 0 14 1
A AnalysisRequest.getVATAmount() 0 14 2
B AnalysisRequest.getAnalysisServiceSettings() 0 29 8
A AnalysisRequest.getRawSecondaryAnalysisRequests() 0 6 1
A AnalysisRequest.getDiscountAmount() 0 11 2
A AnalysisRequest.getMaxDateSampled() 0 7 2
A AnalysisRequest.getSubtotal() 0 5 1
A AnalysisRequest.setDateReceived() 0 8 2
B AnalysisRequest.isAnalysisServiceHidden() 0 28 6
A AnalysisRequest.getDateVerified() 0 5 1
A AnalysisRequest.getHazardous() 0 9 2
A AnalysisRequest.createAttachment() 0 17 1
A AnalysisRequest.getPriorityText() 0 8 2
A AnalysisRequest.printInvoice() 0 7 1
A AnalysisRequest.isOpen() 0 8 3
A AnalysisRequest.getOtherRejectionReasons() 0 7 2
A AnalysisRequest.getSubtotalTotalPrice() 0 5 1
A AnalysisRequest.isRootAncestor() 0 9 2
A AnalysisRequest.getVerifiersIDs() 0 9 2
A AnalysisRequest.getDescendantsUIDs() 0 7 1
A AnalysisRequest.current_date() 0 5 1
B AnalysisRequest.getPrinted() 0 25 6
A AnalysisRequest.getSampleConditionTitle() 0 8 2
B AnalysisRequest.process_inline_images() 0 50 5
A AnalysisRequest.getDescendants() 0 21 4
A AnalysisRequest.getPrioritySortkey() 0 10 1
A AnalysisRequest.getRetest() 0 6 1
A AnalysisRequest.getRawRetest() 0 7 2
A AnalysisRequest.getVerifiers() 0 12 3
A AnalysisRequest.isInvalid() 0 5 1
A AnalysisRequest.setParentAnalysisRequest() 0 13 4
A AnalysisRequest.getSecondaryAnalysisRequests() 0 6 1
A AnalysisRequest.getReports() 0 16 4
A AnalysisRequest.get_ARAttachment() 0 2 1
B AnalysisRequest.setSpecification() 0 31 6
B AnalysisRequest.getVerifier() 0 29 7
A AnalysisRequest.sortable_title() 0 5 1
A AnalysisRequest.getQCAnalyses() 0 29 4
A AnalysisRequest.getProgress() 0 28 5
A AnalysisRequest.getRejecter() 0 18 4
A AnalysisRequest.getWorksheets() 0 14 3
B AnalysisRequest.setResultsRange() 0 23 6
A AnalysisRequest.getContainers() 0 7 1
A AnalysisRequest.setDateSampled() 0 8 2
A AnalysisRequest.getDepartments() 0 10 4
A AnalysisRequest.isPartition() 0 4 1
A AnalysisRequest.get_profiles_query() 0 14 1
A AnalysisRequest.getSubtotalVATAmount() 0 5 1
A AnalysisRequest.setPriority() 0 8 3
A AnalysisRequest.set_ARAttachment() 0 2 1
A AnalysisRequest.setSamplingDate() 0 8 2
A AnalysisRequest.getReceivedBy() 0 7 2
A AnalysisRequest.getPreservers() 0 2 1
A AnalysisRequest.addAttachment() 0 17 4
A AnalysisRequest.createInvoice() 0 17 2
A AnalysisRequest.getRawReports() 0 21 1
B AnalysisRequest.getResultsInterpretationByDepartment() 0 20 6
A AnalysisRequest.Description() 0 4 1
A AnalysisRequest.setResultsInterpretationDepts() 0 25 3
A AnalysisRequest.getSamplers() 0 2 1
A AnalysisRequest.Title() 0 3 1
A AnalysisRequest.getTotalPrice() 0 10 1
A AnalysisRequest.getSelectedRejectionReasons() 0 10 2
A AnalysisRequest.getStorageLocationTitle() 0 8 2
A AnalysisRequest.getSamplingDeviationTitle() 0 9 2
A AnalysisRequest.bika_setup() 0 3 1
A AnalysisRequest.getDatePublished() 0 5 1
C AnalysisRequest.setProfiles() 0 54 10
A AnalysisRequest.getSamplingRequired() 0 5 1
A AnalysisRequest.getAncestors() 0 10 3

How to fix   Complexity   

Complexity

Complex classes like bika.lims.content.analysisrequest often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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