bika.lims.content.analysisrequest   F
last analyzed

Complexity

Total Complexity 232

Size/Duplication

Total Lines 2601
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 232
eloc 1730
dl 0
loc 2601
rs 0.8
c 0
b 0
f 0

91 Methods

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