Passed
Push — 2.x ( 403ab7...ca74b9 )
by Jordi
06:59
created

ajaxAnalysisRequestAddView.create_sample()   B

Complexity

Conditions 6

Size

Total Lines 38
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 38
rs 8.6166
c 0
b 0
f 0
cc 6
nop 5
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
import copy
21
import json
22
from collections import OrderedDict
23
from datetime import datetime
24
from datetime import timedelta
25
26
import six
27
import transaction
28
from bika.lims import POINTS_OF_CAPTURE
29
from bika.lims import api
30
from bika.lims import bikaMessageFactory as _
31
from bika.lims import logger
32
from bika.lims.api.analysisservice import get_calculation_dependencies_for
33
from bika.lims.api.security import check_permission
34
from bika.lims.decorators import returns_json
35
from bika.lims.interfaces import IAddSampleConfirmation
36
from bika.lims.interfaces import IAddSampleFieldsFlush
37
from bika.lims.interfaces import IAddSampleObjectInfo
38
from bika.lims.interfaces import IAddSampleRecordsValidator
39
from bika.lims.interfaces import IGetDefaultFieldValueARAddHook
40
from bika.lims.interfaces.field import IUIDReferenceField
41
from bika.lims.utils.analysisrequest import create_analysisrequest as crar
42
from BTrees.OOBTree import OOBTree
43
from DateTime import DateTime
44
from plone import protect
45
from plone.memoize import view as viewcache
46
from plone.memoize.volatile import DontCache
47
from plone.memoize.volatile import cache
48
from plone.protect.interfaces import IDisableCSRFProtection
49
from Products.Archetypes.interfaces import IField
50
from Products.CMFPlone.utils import safe_unicode
51
from Products.Five.browser import BrowserView
52
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
53
from senaite.core.api import dtime
54
from senaite.core.catalog import CONTACT_CATALOG
55
from senaite.core.catalog import SETUP_CATALOG
56
from senaite.core.interfaces import IAfterCreateSampleHook
57
from senaite.core.p3compat import cmp
58
from senaite.core.permissions import TransitionMultiResults
59
from senaite.core.registry import get_registry_record
60
from zope.annotation.interfaces import IAnnotations
61
from zope.component import getAdapters
62
from zope.component import queryAdapter
63
from zope.component import subscribers
64
from zope.i18n.locales import locales
65
from zope.i18nmessageid import Message
66
from zope.interface import alsoProvides
67
from zope.interface import implements
68
from zope.publisher.interfaces import IPublishTraverse
69
70
AR_CONFIGURATION_STORAGE = "bika.lims.browser.analysisrequest.manage.add"
71
SKIP_FIELD_ON_COPY = ["Sample", "PrimaryAnalysisRequest", "Remarks",
72
                      "NumSamples", "_ARAttachment"]
73
NO_COPY_FIELDS = ["_ARAttachment"]
74
ALLOW_MULTI_PASTE_WIDGET_TYPES = [
75
    # disable paste functionality for date fields, see:
76
    # https://github.com/senaite/senaite.core/pull/2658#discussion_r1946229751
77
    # "senaite.core.browser.widgets.datetimewidget.DateTimeWidget",
78
    "senaite.core.browser.widgets.referencewidget.ReferenceWidget",
79
    "Products.Archetypes.Widget.StringWidget",
80
    "Products.Archetypes.Widget.BooleanWidget",
81
    "bika.lims.browser.widgets.priorityselectionwidget.PrioritySelectionWidget",  # noqa
82
    "bika.lims.browser.widgets.remarkswidget.RemarksWidget",
83
    "bika.lims.browser.widgets.selectionwidget.SelectionWidget",
84
]
85
86
87
def cache_key(method, self, obj):
88
    if obj is None:
89
        raise DontCache
90
    return api.get_cache_key(obj)
91
92
93
class AnalysisRequestAddView(BrowserView):
94
    """AR Add view
95
    """
96
    template = ViewPageTemplateFile("templates/ar_add2.pt")
97
98
    def __init__(self, context, request):
99
        super(AnalysisRequestAddView, self).__init__(context, request)
100
        # disable CSRF protection
101
        alsoProvides(request, IDisableCSRFProtection)
102
        self.request = request
103
        self.context = context
104
        self.fieldvalues = {}
105
        self.tmp_ar = None
106
107
    def __call__(self):
108
        self.portal = api.get_portal()
109
        self.portal_url = self.portal.absolute_url()
110
        self.setup = api.get_senaite_setup()
111
        self.came_from = "add"
112
        self.tmp_ar = self.get_ar()
113
        self.ar_count = self.get_ar_count()
114
        self.fieldvalues = self.generate_fieldvalues(self.ar_count)
115
        self.ShowPrices = self.setup.getShowPrices()
116
        self.theme = api.get_view("senaite_theme")
117
        self.icon = self.theme.icon_url("Sample")
118
        logger.debug("*** Prepared data for {} ARs ***".format(self.ar_count))
119
        return self.template()
120
121
    def get_view_url(self):
122
        """Return the current view url including request parameters
123
        """
124
        request = self.request
125
        url = request.getURL()
126
        qs = request.getHeader("query_string")
127
        if not qs:
128
            return url
129
        return "{}?{}".format(url, qs)
130
131
    # N.B.: We are caching here persistent objects!
132
    #       It should be safe to do this but only on the view object,
133
    #       because it get recreated per request (transaction border).
134
    @viewcache.memoize
135
    def get_object_by_uid(self, uid):
136
        """Get the object by UID
137
        """
138
        logger.debug("get_object_by_uid::UID={}".format(uid))
139
        obj = api.get_object_by_uid(uid, None)
140
        if obj is None:
141
            logger.warn("!! No object found for UID '%s' !!" % uid)
142
        return obj
143
144
    @viewcache.memoize
145
    def analyses_required(self):
146
        """Check if analyses are required
147
        """
148
        setup = api.get_senaite_setup()
149
        return setup.getSampleAnalysesRequired()
150
151
    def get_currency(self):
152
        """Returns the configured currency
153
        """
154
        setup = api.get_senaite_setup()
155
        currency = setup.getCurrency()
156
        currencies = locales.getLocale("en").numbers.currencies
157
        return currencies[currency]
158
159
    def get_ar_count(self):
160
        """Return the ar_count request paramteter
161
        """
162
        ar_count = 1
163
        try:
164
            ar_count = int(self.request.form.get("ar_count", 1))
165
        except (TypeError, ValueError):
166
            ar_count = 1
167
        return ar_count
168
169
    def get_ar(self):
170
        """Create a temporary AR to fetch the fields from
171
        """
172
        if not self.tmp_ar:
173
            logger.debug("*** CREATING TEMPORARY AR ***")
174
            self.tmp_ar = self.context.restrictedTraverse(
175
                "portal_factory/AnalysisRequest/Request new analyses")
176
        return self.tmp_ar
177
178
    def get_ar_schema(self):
179
        """Return the AR schema
180
        """
181
        logger.debug("*** GET AR SCHEMA ***")
182
        ar = self.get_ar()
183
        return ar.Schema()
184
185
    def get_ar_fields(self):
186
        """Return the AR schema fields (including extendend fields)
187
        """
188
        logger.debug("*** GET AR FIELDS ***")
189
        schema = self.get_ar_schema()
190
        return schema.fields()
191
192
    def get_fieldname(self, field, arnum):
193
        """Generate a new fieldname with a '-<arnum>' suffix
194
        """
195
        name = field.getName()
196
        # ensure we have only *one* suffix
197
        base_name = name.split("-")[0]
198
        suffix = "-{}".format(arnum)
199
        return "{}{}".format(base_name, suffix)
200
201
    def get_input_widget(self, fieldname, arnum=0, **kw):
202
        """Get the field widget of the AR in column <arnum>
203
204
        :param fieldname: The base fieldname
205
        :type fieldname: string
206
        """
207
208
        # temporary AR Context
209
        context = self.get_ar()
210
        # request = self.request
211
        schema = context.Schema()
212
213
        # get original field in the schema from the base_fieldname
214
        base_fieldname = fieldname.split("-")[0]
215
        field = context.getField(base_fieldname)
216
217
        # fieldname with -<arnum> suffix
218
        new_fieldname = self.get_fieldname(field, arnum)
219
        new_field = field.copy(name=new_fieldname)
220
221
        # get the default value for this field
222
        fieldvalues = self.fieldvalues
223
        field_value = fieldvalues.get(new_fieldname)
224
        # request_value = request.form.get(new_fieldname)
225
        # value = request_value or field_value
226
        value = field_value
227
228
        def getAccessor(instance):
229
            def accessor(**kw):
230
                return value
231
            return accessor
232
233
        # inject the new context for the widget renderer
234
        # see: Products.Archetypes.Renderer.render
235
        kw["here"] = context
236
        kw["context"] = context
237
        kw["fieldName"] = new_fieldname
238
239
        # make the field available with this name
240
        # XXX: This is a hack to make the widget available in the template
241
        schema._fields[new_fieldname] = new_field
242
        new_field.getAccessor = getAccessor
243
        new_field.getEditAccessor = getAccessor
244
245
        # set the default value
246
        form = dict()
247
        form[new_fieldname] = value
248
        self.request.form.update(form)
249
        logger.debug("get_input_widget: fieldname={} arnum={} "
250
                     "-> new_fieldname={} value={}".format(
251
                         fieldname, arnum, new_fieldname, value))
252
        widget = context.widget(new_fieldname, **kw)
253
        return widget
254
255
    def get_copy_from(self):
256
        """Returns a mapping of UID index -> AR object
257
        """
258
        # Create a mapping of source ARs for copy
259
        copy_from = self.request.form.get("copy_from", "").split(",")
260
        # clean out empty strings
261
        copy_from_uids = filter(lambda x: x, copy_from)
262
        out = dict().fromkeys(range(len(copy_from_uids)))
263
        for n, uid in enumerate(copy_from_uids):
264
            ar = self.get_object_by_uid(uid)
265
            if ar is None:
266
                continue
267
            out[n] = ar
268
        logger.info("get_copy_from: uids={}".format(copy_from_uids))
269
        return out
270
271
    def get_default_value(self, field, context, arnum):
272
        """Get the default value of the field
273
        """
274
        name = field.getName()
275
        default = field.getDefault(context)
276
        if name == "Batch":
277
            batch = self.get_batch()
278
            if batch is not None:
279
                default = batch
280
        if name == "Client":
281
            client = self.get_client()
282
            if client is not None:
283
                default = client
284
        # only set default contact for first column
285
        if name == "Contact" and arnum == 0:
286
            contact = self.get_default_contact()
287
            if contact is not None:
288
                default = contact
289
        if name == "Sample":
290
            sample = self.get_sample()
291
            if sample is not None:
292
                default = sample
293
        # Querying for adapters to get default values from add-ons':
294
        # We don't know which fields the form will render since
295
        # some of them may come from add-ons. In order to obtain the default
296
        # value for those fields we take advantage of adapters. Adapters
297
        # registration should have the following format:
298
        # < adapter
299
        #   factory = ...
300
        #   for = "*"
301
        #   provides = "bika.lims.interfaces.IGetDefaultFieldValueARAddHook"
302
        #   name = "<fieldName>_default_value_hook"
303
        # / >
304
        hook_name = name + '_default_value_hook'
305
        adapter = queryAdapter(
306
            self.request,
307
            name=hook_name,
308
            interface=IGetDefaultFieldValueARAddHook)
309
        if adapter is not None:
310
            default = adapter(self.context)
311
        logger.debug("get_default_value: context={} field={} value={} arnum={}"
312
                     .format(context, name, default, arnum))
313
        return default
314
315
    def get_field_value(self, field, context):
316
        """Get the stored value of the field
317
        """
318
        name = field.getName()
319
        value = context.getField(name).get(context)
320
        logger.debug("get_field_value: context={} field={} value={}".format(
321
            context, name, value))
322
        return value
323
324
    def get_client(self):
325
        """Returns the Client
326
        """
327
        context = self.context
328
        parent = api.get_parent(context)
329
        if context.portal_type == "Client":
330
            return context
331
        elif parent.portal_type == "Client":
332
            return parent
333
        elif context.portal_type == "Batch":
334
            return context.getClient()
335
        elif parent.portal_type == "Batch":
336
            return context.getClient()
337
        return None
338
339
    def get_sample(self):
340
        """Returns the Sample
341
        """
342
        context = self.context
343
        if context.portal_type == "Sample":
344
            return context
345
        return None
346
347
    def get_batch(self):
348
        """Returns the Batch
349
        """
350
        context = self.context
351
        parent = api.get_parent(context)
352
        if context.portal_type == "Batch":
353
            return context
354
        elif parent.portal_type == "Batch":
355
            return parent
356
        return None
357
358
    def get_parent_ar(self, ar):
359
        """Returns the parent AR
360
        """
361
        parent = ar.getParentAnalysisRequest()
362
363
        # Return immediately if we have no parent
364
        if parent is None:
365
            return None
366
367
        # Walk back the chain until we reach the source AR
368
        while True:
369
            pparent = parent.getParentAnalysisRequest()
370
            if pparent is None:
371
                break
372
            # remember the new parent
373
            parent = pparent
374
375
        return parent
376
377
    def generate_fieldvalues(self, count=1):
378
        """Returns a mapping of '<fieldname>-<count>' to the default value
379
        of the field or the field value of the source AR
380
        """
381
        ar_context = self.get_ar()
382
383
        # mapping of UID index to AR objects {1: <AR1>, 2: <AR2> ...}
384
        copy_from = self.get_copy_from()
385
386
        out = {}
387
        # the original schema fields of an AR (including extended fields)
388
        fields = self.get_ar_fields()
389
390
        # generate fields for all requested ARs
391
        for arnum in range(count):
392
            source = copy_from.get(arnum)
393
            parent = None
394
            if source is not None:
395
                parent = self.get_parent_ar(source)
396
397
            for field in fields:
398
                value = None
399
                fieldname = field.getName()
400
                if source and fieldname not in SKIP_FIELD_ON_COPY:
401
                    # get the field value stored on the source
402
                    context = parent or source
403
                    value = self.get_field_value(field, context)
404
                else:
405
                    # get the default value of this field
406
                    value = self.get_default_value(
407
                        field, ar_context, arnum=arnum)
408
409
                # Filter out analyses in certain workflow states when copying
410
                if fieldname == "Analyses" and value:
411
                    skip_states = self.get_skip_analyses_states()
412
                    value = self.filter_objs_with_states(
413
                        value, filter_states=skip_states)
414
415
                    # Filter out partition analyses if configured
416
                    if self.get_skip_partition_analyses() and source:
417
                        value = self.filter_partition_analyses(value, source)
418
419
                # store the value on the new fieldname
420
                new_fieldname = self.get_fieldname(field, arnum)
421
                out[new_fieldname] = value
422
423
        return out
424
425
    def get_default_contact(self, client=None):
426
        """Logic refactored from JavaScript:
427
428
        * If client only has one contact, and the analysis request comes from
429
        * a client, then Auto-complete first Contact field.
430
        * If client only has one contect, and the analysis request comes from
431
        * a batch, then Auto-complete all Contact field.
432
433
        :returns: The default contact for the AR
434
        :rtype: Client object or None
435
        """
436
        catalog = api.get_tool(CONTACT_CATALOG)
437
        client = client or self.get_client()
438
        path = api.get_path(self.context)
439
        if client:
440
            path = api.get_path(client)
441
        query = {
442
            "portal_type": "Contact",
443
            "path": {
444
                "query": path,
445
                "depth": 1
446
            },
447
            "is_active": True,
448
        }
449
        contacts = catalog(query)
450
        if len(contacts) == 1:
451
            return api.get_object(contacts[0])
452
        elif client == api.get_current_client():
453
            # Current user is a Client contact. Use current contact
454
            current_user = api.get_current_user()
455
            return api.get_user_contact(current_user,
456
                                        contact_types=["Contact"])
457
458
        return None
459
460
    def getMemberDiscountApplies(self):
461
        """Return if the member discount applies for this client
462
463
        :returns: True if member discount applies for the client
464
        :rtype: bool
465
        """
466
        client = self.get_client()
467
        if client is None:
468
            return False
469
        return client.getMemberDiscountApplies()
470
471
    def is_field_visible(self, field):
472
        """Check if the field is visible
473
        """
474
        context = self.context
475
        fieldname = field.getName()
476
477
        # hide the Client field on client and batch contexts
478
        if fieldname == "Client" and context.portal_type in ("Client", ):
479
            return False
480
481
        # hide the Batch field on batch contexts
482
        if fieldname == "Batch" and context.portal_type in ("Batch", ):
483
            return False
484
485
        return True
486
487
    def get_fields_with_visibility(self, visibility, mode="add"):
488
        """Return the AR fields with the current visibility
489
        """
490
        ar = self.get_ar()
491
        mv = api.get_view("ar_add_manage", context=ar)
492
        mv.get_field_order()
493
494
        out = []
495
        for field in mv.get_fields_with_visibility(visibility, mode):
496
            # check custom field condition
497
            visible = self.is_field_visible(field)
498
            if visible is False and visibility != "hidden":
499
                continue
500
            out.append(field)
501
        return out
502
503
    def get_service_categories(self, restricted=True):
504
        """Return all service categories in the right order
505
506
        :param restricted: Client settings restrict categories
507
        :type restricted: bool
508
        :returns: Category catalog results
509
        :rtype: brains
510
        """
511
        bsc = api.get_tool("senaite_catalog_setup")
512
        query = {
513
            "portal_type": "AnalysisCategory",
514
            "is_active": True,
515
            "sort_on": "sortable_title",
516
        }
517
        categories = bsc(query)
518
        client = self.get_client()
519
        if client and restricted:
520
            restricted_categories = client.getRestrictedCategories()
521
            restricted_category_ids = map(
522
                lambda c: c.getId(), restricted_categories)
523
            # keep correct order of categories
524
            if restricted_category_ids:
525
                categories = filter(
526
                    lambda c: c.getId in restricted_category_ids, categories)
0 ignored issues
show
introduced by
The variable restricted_category_ids does not seem to be defined in case client and restricted on line 519 is False. Are you sure this can never be the case?
Loading history...
527
        return categories
528
529
    def get_points_of_capture(self):
530
        items = POINTS_OF_CAPTURE.items()
531
        return OrderedDict(items)
532
533
    def get_services(self, poc="lab"):
534
        """Return all Services
535
536
        :param poc: Point of capture (lab/field)
537
        :type poc: string
538
        :returns: Mapping of category -> list of services
539
        :rtype: dict
540
        """
541
        bsc = api.get_tool("senaite_catalog_setup")
542
        query = {
543
            "portal_type": "AnalysisService",
544
            "point_of_capture": poc,
545
            "is_active": True,
546
            "sort_on": "sortable_title",
547
        }
548
        services = bsc(query)
549
        categories = self.get_service_categories(restricted=False)
550
        analyses = {key: [] for key in map(lambda c: c.Title, categories)}
551
552
        # append the empty category as well
553
        analyses[""] = []
554
555
        for brain in services:
556
            category = self.get_category_title(brain)
557
            if category in analyses:
558
                analyses[category].append(brain)
559
        return analyses
560
561
    def get_category_title(self, service):
562
        """Return the title of the category the service is assigned to
563
        """
564
        service = api.get_object(service)
565
        cat_uid = service.getRawCategory()
566
        if not cat_uid:
567
            return ""
568
        cat = self.get_object_by_uid(cat_uid)
569
        return api.get_title(cat)
570
571
    @cache(cache_key)
572
    def get_service_uid_from(self, analysis):
573
        """Return the service from the analysis
574
        """
575
        analysis = api.get_object(analysis)
576
        return api.get_uid(analysis.getAnalysisService())
577
578
    def is_service_selected(self, service):
579
        """Checks if the given service is selected by one of the ARs.
580
        This is used to make the whole line visible or not.
581
        """
582
        service_uid = api.get_uid(service)
583
        for arnum in range(self.ar_count):
584
            analyses = self.fieldvalues.get("Analyses-{}".format(arnum))
585
            if not analyses:
586
                continue
587
            service_uids = map(self.get_service_uid_from, analyses)
588
            if service_uid in service_uids:
589
                return True
590
        return False
591
592
    def show_copy_button_for(self, field=None):
593
        """ Show copy/paste button for field
594
        """
595
        if self.ar_count <= 1:
596
            return False
597
        if field and field.getName() in NO_COPY_FIELDS:
598
            return False
599
        return True
600
601
    def show_paste_button_for(self, field=None):
602
        """ Show paste button for field
603
        """
604
        allowed = self.get_allowed_multi_paste_fields()
605
        if allowed:
606
            field_name = field.getName()
607
            return field_name in allowed
608
609
        # fallback to widget type based lookup
610
        try:
611
            widget_type = field.widget.getType()
612
        except AttributeError:
613
            widget_type = None
614
        return widget_type in ALLOW_MULTI_PASTE_WIDGET_TYPES
615
616
    def get_allowed_multi_paste_fields(self):
617
        """Returns a list of fields that allow multi paste
618
        """
619
        key = "sample_add_form_allow_multi_paste"
620
        record = get_registry_record(key)
621
        if not record:
622
            return []
623
        # convert to plain list to avoid persistent references
624
        return list(record)
625
626
    def get_skip_analyses_states(self):
627
        """Returns a list of analyses WF states to skip on copy
628
        """
629
        key = "sample_add_form_skip_analyses_in_states"
630
        record = get_registry_record(key)
631
        if not record:
632
            return []
633
        # convert to plain list to avoid persistent references
634
        return list(record)
635
636
    def get_skip_partition_analyses(self):
637
        """Returns whether to skip partition analyses on copy
638
        """
639
        key = "sample_add_form_skip_partition_analyses"
640
        record = get_registry_record(key)
641
        return bool(record)
642
643
    def filter_objs_with_states(self, objs, filter_states=None):
644
        """Filter out objects that are in the given workflow states
645
646
        :param objs: List of objects to filter
647
        :param filter_states: List of workflow state IDs to exclude
648
        :return: List of objects not in the filter_states
649
        """
650
        if not filter_states:
651
            return objs
652
        if not isinstance(filter_states, (list, tuple)):
653
            return objs
654
        filtered = []
655
        for obj in objs:
656
            status = api.get_review_status(obj)
657
            if status not in filter_states:
658
                filtered.append(obj)
659
        return filtered
660
661
    def filter_partition_analyses(self, analyses, source):
662
        """Filter out analyses that belong to partitions
663
664
        Only keeps analyses that directly belong to the source sample.
665
        Analyses from partitions are identified by checking if they are
666
        direct children of the source sample.
667
668
        :param analyses: List of analysis brains/objects to filter
669
        :param source: The source sample object
670
        :return: List of analyses that belong directly to the source sample
671
        """
672
        if not analyses or not source:
673
            return analyses
674
675
        # Get the physical paths of analyses that directly belong to the source
676
        source_analysis_paths = set()
677
        for analysis in source.objectValues("Analysis"):
678
            source_analysis_paths.add(api.get_path(analysis))
679
680
        # Filter the analyses to keep only those in the source
681
        filtered = []
682
        for analysis in analyses:
683
            # Get the object if it's a brain
684
            analysis_path = api.get_path(analysis)
685
            if analysis_path in source_analysis_paths:
686
                filtered.append(analysis)
687
688
        return filtered
689
690
691
class AnalysisRequestManageView(BrowserView):
692
    """AR Manage View
693
    """
694
    template = ViewPageTemplateFile("templates/ar_add_manage.pt")
695
696
    def __init__(self, context, request):
697
        # disable CSRF protection
698
        alsoProvides(request, IDisableCSRFProtection)
699
        self.context = context
700
        self.request = request
701
        self.tmp_ar = None
702
703
    def __call__(self):
704
        protect.CheckAuthenticator(self.request.form)
705
        form = self.request.form
706
        if form.get("submitted", False) and form.get("save", False):
707
            order = form.get("order")
708
            self.set_field_order(order)
709
            visibility = form.get("visibility")
710
            self.set_field_visibility(visibility)
711
        if form.get("submitted", False) and form.get("reset", False):
712
            self.flush()
713
        return self.template()
714
715
    def get_ar(self):
716
        if not self.tmp_ar:
717
            self.tmp_ar = self.context.restrictedTraverse(
718
                "portal_factory/AnalysisRequest/Request new analyses")
719
        return self.tmp_ar
720
721
    def get_annotation(self):
722
        setup = api.get_senaite_setup()
723
        return IAnnotations(setup)
724
725
    @property
726
    def storage(self):
727
        annotation = self.get_annotation()
728
        if annotation.get(AR_CONFIGURATION_STORAGE) is None:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable AR_CONFIGURATION_STORAGE does not seem to be defined.
Loading history...
729
            annotation[AR_CONFIGURATION_STORAGE] = OOBTree()
730
        return annotation[AR_CONFIGURATION_STORAGE]
731
732
    def flush(self):
733
        annotation = self.get_annotation()
734
        if annotation.get(AR_CONFIGURATION_STORAGE) is not None:
735
            del annotation[AR_CONFIGURATION_STORAGE]
736
737
    def set_field_order(self, order):
738
        self.storage.update({"order": order})
739
740
    def get_field_order(self):
741
        order = self.storage.get("order")
742
        if order is None:
743
            return map(lambda f: f.getName(), self.get_fields())
744
        return order
745
746
    def set_field_visibility(self, visibility):
747
        self.storage.update({"visibility": visibility})
748
749
    def get_field_visibility(self):
750
        return self.storage.get("visibility")
751
752
    def is_field_visible(self, field):
753
        if field.required:
754
            return True
755
        visibility = self.get_field_visibility()
756
        if visibility is None:
757
            return True
758
        return visibility.get(field.getName(), True)
759
760
    def get_field(self, name):
761
        """Get AR field by name
762
        """
763
        ar = self.get_ar()
764
        return ar.getField(name)
765
766
    def get_fields(self):
767
        """Return all AR fields
768
        """
769
        ar = self.get_ar()
770
        return ar.Schema().fields()
771
772
    def get_sorted_fields(self):
773
        """Return the sorted fields
774
        """
775
        inf = float("inf")
776
        order = self.get_field_order()
777
778
        def field_cmp(field1, field2):
779
            _n1 = field1.getName()
780
            _n2 = field2.getName()
781
            _i1 = _n1 in order and order.index(_n1) + 1 or inf
782
            _i2 = _n2 in order and order.index(_n2) + 1 or inf
783
            return cmp(_i1, _i2)
784
785
        return sorted(self.get_fields(), cmp=field_cmp)
786
787
    def get_fields_with_visibility(self, visibility="edit", mode="add"):
788
        """Return the fields with visibility
789
        """
790
        fields = self.get_sorted_fields()
791
792
        out = []
793
794
        for field in fields:
795
            v = field.widget.isVisible(
796
                self.context, mode, default='invisible', field=field)
797
798
            if self.is_field_visible(field) is False:
799
                v = "hidden"
800
801
            visibility_guard = True
802
            # visibility_guard is a widget field defined in the schema in order
803
            # to know the visibility of the widget when the field is related to
804
            # a dynamically changing content such as workflows. For instance
805
            # those fields related to the workflow will be displayed only if
806
            # the workflow is enabled, otherwise they should not be shown.
807
            if 'visibility_guard' in dir(field.widget):
808
                visibility_guard = eval(field.widget.visibility_guard)
809
            if v == visibility and visibility_guard:
810
                out.append(field)
811
812
        return out
813
814
815
class ajaxAnalysisRequestAddView(AnalysisRequestAddView):
816
    """Ajax helpers for the analysis request add form
817
    """
818
    implements(IPublishTraverse)
819
820
    def __init__(self, context, request):
821
        super(ajaxAnalysisRequestAddView, self).__init__(context, request)
822
        self.context = context
823
        self.request = request
824
        self.traverse_subpath = []
825
        # Errors are aggregated here, and returned together to the browser
826
        self.errors = {}
827
828
    def publishTraverse(self, request, name):
829
        """ get's called before __call__ for each path name
830
        """
831
        self.traverse_subpath.append(name)
832
        return self
833
834
    @returns_json
835
    def __call__(self):
836
        """Dispatch the path to a method and return JSON.
837
        """
838
        protect.CheckAuthenticator(self.request.form)
839
        protect.PostOnly(self.request.form)
840
841
        if len(self.traverse_subpath) != 1:
842
            return self.error("Not found", status=404)
843
        func_name = "ajax_{}".format(self.traverse_subpath[0])
844
        func = getattr(self, func_name, None)
845
        if func is None:
846
            return self.error("Invalid function", status=400)
847
        return func()
848
849
    def error(self, message, status=500, **kw):
850
        """Set a JSON error object and a status to the response
851
        """
852
        self.request.response.setStatus(status)
853
        result = {"success": False, "errors": message}
854
        result.update(kw)
855
        return result
856
857
    def to_iso_date(self, dt):
858
        """Return the ISO representation of a date object
859
        """
860
        if dt is None:
861
            return ""
862
        if isinstance(dt, DateTime):
863
            return dt.ISO8601()
864
        if isinstance(dt, datetime):
865
            return dt.isoformat()
866
        raise TypeError("{} is neiter an instance of DateTime nor datetime"
867
                        .format(repr(dt)))
868
869
    @viewcache.memoize
870
    def is_uid_reference_field(self, fieldname):
871
        """Checks if the field is a UID reference field
872
        """
873
        schema = self.get_ar_schema()
874
        field = schema.get(fieldname)
875
        if field is None:
876
            return False
877
        return IUIDReferenceField.providedBy(field)
878
879
    @viewcache.memoize
880
    def is_multi_reference_field(self, fieldname):
881
        """Checks if the field is a multi UID reference field
882
        """
883
        if not self.is_uid_reference_field(fieldname):
884
            return False
885
        schema = self.get_ar_schema()
886
        field = schema.get(fieldname)
887
        return getattr(field, "multiValued", False)
888
889
    def get_records(self):
890
        """Returns a list of AR records
891
892
        Fields coming from `request.form` have a number prefix, e.g. Contact-0.
893
        Fields with the same suffix number are grouped together in a record.
894
        Each record represents the data for one column in the AR Add form and
895
        contains a mapping of the fieldName (w/o prefix) -> value.
896
897
        Example:
898
        [{"Contact": "Rita Mohale", ...}, {Contact: "Neil Standard"} ...]
899
        """
900
        form = self.request.form
901
        ar_count = self.get_ar_count()
902
903
        records = []
904
        # Group belonging AR fields together
905
        for arnum in range(ar_count):
906
            record = {}
907
            s1 = "-{}".format(arnum)
908
            keys = filter(lambda key: s1 in key, form.keys())
0 ignored issues
show
introduced by
The variable s1 does not seem to be defined in case the for loop on line 905 is not entered. Are you sure this can never be the case?
Loading history...
909
            for key in keys:
910
                new_key = key.replace(s1, "")
911
                value = form.get(key)
912
                if self.is_uid_reference_field(new_key):
913
                    # handle new UID reference fields that store references in
914
                    # a textarea (one UID per line)
915
                    uids = value.split("\r\n")
916
                    # remove empties
917
                    uids = list(filter(None, uids))
918
                    if self.is_multi_reference_field(new_key):
919
                        value = uids
920
                    else:
921
                        value = uids[0] if len(uids) > 0 else ""
922
                record[new_key] = value
923
            records.append(record)
924
        return records
925
926
    def get_uids_from_record(self, record, key):
927
        """Returns a list of parsed UIDs from a single form field identified by
928
        the given key.
929
930
        A form field of an UID reference can contain an empty value, a single
931
        UID or multiple UIDs separated by a \r\n.
932
933
        This method parses the UID value and returns a list of non-empty UIDs.
934
        """
935
        if not self.is_uid_reference_field(key):
936
            return []
937
        value = record.get(key, None)
938
        if not value:
939
            return []
940
        if api.is_string(value):
941
            value = value.split("\r\n")
942
        return list(filter(None, value))
943
944
    @cache(cache_key)
945
    def get_base_info(self, obj):
946
        """Returns the base info of an object
947
        """
948
        if obj is None:
949
            return {}
950
951
        info = {
952
            "id": api.get_id(obj),
953
            "uid": api.get_uid(obj),
954
            "url": api.get_url(obj),
955
            "title": api.get_title(obj),
956
            "field_values": {},
957
            "filter_queries": {},
958
        }
959
960
        return info
961
962
    @cache(cache_key)
963
    def get_client_info(self, obj):
964
        """Returns the client info of an object
965
        """
966
        info = self.get_base_info(obj)
967
968
        # Set the default contact, but only if empty. The Contact field is
969
        # flushed each time the Client changes, so we can assume that if there
970
        # is a selected contact, it belongs to current client already
971
        default_contact = self.get_default_contact(client=obj)
972
        if default_contact:
973
            contact_info = self.get_contact_info(default_contact)
974
            contact_info.update({"if_empty": True})
975
            info["field_values"].update({
976
                "Contact": contact_info
977
            })
978
979
        # Set default CC Email field
980
        info["field_values"].update({
981
            "CCEmails": {"value": obj.getCCEmails(), "if_empty": True}
982
        })
983
984
        return info
985
986
    @cache(cache_key)
987
    def get_contact_info(self, obj):
988
        """Returns the client info of an object
989
        """
990
991
        info = self.get_base_info(obj)
992
        fullname = obj.getFullname()
993
        email = obj.getEmailAddress()
994
995
        # Note: It might get a circular dependency when calling:
996
        #       map(self.get_contact_info, obj.getCCContact())
997
        cccontacts = []
998
        for contact in obj.getCCContact():
999
            uid = api.get_uid(contact)
1000
            fullname = contact.getFullname()
1001
            email = contact.getEmailAddress()
1002
            cccontacts.append({
1003
                "uid": uid,
1004
                "title": fullname,
1005
                "fullname": fullname,
1006
                "email": email
1007
            })
1008
1009
        info.update({
1010
            "fullname": fullname,
1011
            "email": email,
1012
            "field_values": {
1013
                "CCContact": cccontacts
1014
            },
1015
        })
1016
1017
        return info
1018
1019
    @cache(cache_key)
1020
    def get_service_info(self, obj):
1021
        """Returns the info for a Service
1022
        """
1023
        info = self.get_base_info(obj)
1024
1025
        info.update({
1026
            "short_title": obj.getShortTitle(),
1027
            "scientific_name": obj.getScientificName(),
1028
            "unit": obj.getUnit(),
1029
            "keyword": obj.getKeyword(),
1030
            "methods": map(self.get_method_info, obj.getMethods()),
1031
            "calculation": self.get_calculation_info(obj.getCalculation()),
1032
            "price": obj.getPrice(),
1033
            "currency_symbol": self.get_currency().symbol,
1034
            "accredited": obj.getAccredited(),
1035
            "category": obj.getCategoryTitle(),
1036
            "poc": obj.getPointOfCapture(),
1037
            "conditions": self.get_conditions_info(obj),
1038
            "max_holding_time": obj.getMaxHoldingTime(),
1039
        })
1040
1041
        dependencies = get_calculation_dependencies_for(obj).values()
1042
        info["dependencies"] = map(self.get_base_info, dependencies)
1043
        return info
1044
1045
    @cache(cache_key)
1046
    def get_template_info(self, obj):
1047
        """Returns the info for a Template
1048
        """
1049
        client = self.get_client()
1050
        client_uid = api.get_uid(client) if client else ""
1051
1052
        sample_type = obj.getSampleType()
1053
        sample_type_uid = api.get_uid(sample_type) if sample_type else ""
1054
        sample_type_title = sample_type.Title() if sample_type else ""
1055
1056
        sample_point = obj.getSamplePoint()
1057
        sample_point_uid = api.get_uid(sample_point) if sample_point else ""
1058
        sample_point_title = sample_point.Title() if sample_point else ""
1059
1060
        service_uids = []
1061
        analyses_partitions = {}
1062
        services = obj.getRawServices()
1063
1064
        for record in services:
1065
            service_uid = record.get("uid")
1066
            service_uids.append(service_uid)
1067
            analyses_partitions[service_uid] = record.get("part_id")
1068
1069
        info = self.get_base_info(obj)
1070
        info.update({
1071
            "analyses_partitions": analyses_partitions,
1072
            "client_uid": client_uid,
1073
            "composite": obj.getComposite(),
1074
            "partitions": obj.getPartitions(),
1075
            "sample_point_title": sample_point_title,
1076
            "sample_point_uid": sample_point_uid,
1077
            "sample_type_title": sample_type_title,
1078
            "sample_type_uid": sample_type_uid,
1079
            "service_uids": service_uids,
1080
        })
1081
        return info
1082
1083
    @cache(cache_key)
1084
    def get_profile_info(self, obj):
1085
        """Returns the info for a Profile
1086
        """
1087
        info = self.get_base_info(obj)
1088
        info.update({})
1089
        return info
1090
1091
    @cache(cache_key)
1092
    def get_method_info(self, obj):
1093
        """Returns the info for a Method
1094
        """
1095
        info = self.get_base_info(obj)
1096
        info.update({})
1097
        return info
1098
1099
    @cache(cache_key)
1100
    def get_calculation_info(self, obj):
1101
        """Returns the info for a Calculation
1102
        """
1103
        info = self.get_base_info(obj)
1104
        info.update({})
1105
        return info
1106
1107
    @cache(cache_key)
1108
    def get_sampletype_info(self, obj):
1109
        """Returns the info for a Sample Type
1110
        """
1111
        info = self.get_base_info(obj)
1112
1113
        info.update({
1114
            "prefix": obj.getPrefix(),
1115
            "minimum_volume": obj.getMinimumVolume(),
1116
            "hazardous": obj.getHazardous(),
1117
            "retention_period": obj.getRetentionPeriod(),
1118
        })
1119
1120
        return info
1121
1122
    @cache(cache_key)
1123
    def get_primaryanalysisrequest_info(self, obj):
1124
        """Returns the info for a Primary Sample
1125
        """
1126
        info = self.get_base_info(obj)
1127
1128
        batch = obj.getBatch()
1129
        client = obj.getClient()
1130
        sample_type = obj.getSampleType()
1131
        sample_condition = obj.getSampleCondition()
1132
        storage_location = obj.getStorageLocation()
1133
        sample_point = obj.getSamplePoint()
1134
        container = obj.getContainer()
1135
        deviation = obj.getSamplingDeviation()
1136
        cccontacts = obj.getCCContact() or []
1137
        contact = obj.getContact()
1138
1139
        info.update({
1140
            "composite": obj.getComposite(),
1141
        })
1142
1143
        # Set the fields for which we want the value to be set automatically
1144
        # when the primary sample is selected
1145
        info["field_values"].update({
1146
            "Client": self.to_field_value(client),
1147
            "Contact": self.to_field_value(contact),
1148
            "CCContact": map(self.to_field_value, cccontacts),
1149
            "CCEmails": obj.getCCEmails(),
1150
            "Batch": self.to_field_value(batch),
1151
            "DateSampled": {"value": self.to_iso_date(obj.getDateSampled())},
1152
            "SamplingDate": {"value": self.to_iso_date(obj.getSamplingDate())},
1153
            "SampleType": self.to_field_value(sample_type),
1154
            "EnvironmentalConditions": {
1155
                "value": obj.getEnvironmentalConditions(),
1156
            },
1157
            "ClientSampleID": {"value": obj.getClientSampleID()},
1158
            "ClientReference": {"value": obj.getClientReference()},
1159
            "ClientOrderNumber": {"value": obj.getClientOrderNumber()},
1160
            "SampleCondition": self.to_field_value(sample_condition),
1161
            "SamplePoint": self.to_field_value(sample_point),
1162
            "StorageLocation": self.to_field_value(storage_location),
1163
            "Container": self.to_field_value(container),
1164
            "SamplingDeviation": self.to_field_value(deviation),
1165
            "Composite": {"value": obj.getComposite()}
1166
        })
1167
1168
        return info
1169
1170
    @cache(cache_key)
1171
    def get_conditions_info(self, obj):
1172
        conditions = obj.getConditions()
1173
        for condition in conditions:
1174
            choices = condition.get("choices", "")
1175
            options = filter(None, choices.split('|'))
1176
            if options:
1177
                condition.update({"options": options})
1178
        return conditions
1179
1180
    @cache(cache_key)
1181
    def to_field_value(self, obj):
1182
        return {
1183
            "uid": obj and api.get_uid(obj) or "",
1184
            "title": obj and api.get_title(obj) or ""
1185
        }
1186
1187
    def to_attachment_record(self, fileupload):
1188
        """Returns a dict-like structure with suitable information for the
1189
        proper creation of Attachment objects
1190
        """
1191
        if not fileupload.filename:
1192
            # ZPublisher.HTTPRequest.FileUpload is empty
1193
            return None
1194
        return {
1195
            "AttachmentFile": fileupload,
1196
            "AttachmentType": "",
1197
            "RenderInReport": False,
1198
            "AttachmentKeys": "",
1199
            "Service": "",
1200
        }
1201
1202
    def create_attachment(self, sample, attachment_record):
1203
        """Creates an attachment for the given sample with the information
1204
        provided in attachment_record
1205
        """
1206
        # create the attachment object
1207
        client = sample.getClient()
1208
        attachment = api.create(client, "Attachment", **attachment_record)
1209
        uid = attachment_record.get("Service")
1210
        if not uid:
1211
            # Link the attachment to the sample
1212
            sample.addAttachment(attachment)
1213
            return attachment
1214
1215
        # Link the attachment to analyses with this service uid
1216
        ans = sample.objectValues(spec="Analysis")
1217
        ans = filter(lambda an: an.getRawAnalysisService() == uid, ans)
1218
        for analysis in ans:
1219
            attachments = analysis.getRawAttachment()
1220
            analysis.setAttachment(attachments + [attachment])
1221
1222
        # Assign the attachment to the given condition
1223
        condition_title = attachment_record.get("Condition")
1224
        if not condition_title:
1225
            return attachment
1226
1227
        conditions = sample.getServiceConditions()
1228
        for condition in conditions:
1229
            is_uid = condition.get("uid") == uid
1230
            is_title = condition.get("title") == condition_title
1231
            is_file = condition.get("type") == "file"
1232
            if all([is_uid, is_title, is_file]):
1233
                condition["value"] = api.get_uid(attachment)
1234
        sample.setServiceConditions(conditions)
1235
        return attachment
1236
1237
    def ajax_get_global_settings(self):
1238
        """Returns the global Bika settings
1239
        """
1240
        setup = api.get_senaite_setup()
1241
        settings = {
1242
            "show_prices": setup.getShowPrices(),
1243
        }
1244
        return settings
1245
1246
    def ajax_is_reference_value_allowed(self):
1247
        """Checks if the current reference value is allowed for the query
1248
        """
1249
        payload = self.get_json()
1250
1251
        catalog = payload.get("catalog", "")
1252
        query = payload.get("query", {})
1253
        uids = payload.get("uids", [])
1254
        name = payload.get("name", "")
1255
        label = payload.get("label", "")
1256
        field = label or name
1257
1258
        # Remove sort_limit to prevent false negatives
1259
        query.pop("sort_limit", None)
1260
1261
        # Skip the catalog search if we can assume to be allowed
1262
        white_keys = ["portal_type", "sort_on", "sort_order", "is_active"]
1263
        if set(query.keys()).issubset(white_keys):
1264
            return {"allowed": True}
1265
1266
        if all([catalog, query, uids]):
1267
            # Special handling for Contact fields with getParentUID filter
1268
            # Global contacts should always be allowed regardless of client
1269
            if name in ["Contact", "CCContact"] and "getParentUID" in query:
1270
                from bika.lims.interfaces import IClient
1271
1272
                # Check each selected contact
1273
                for uid in uids:
1274
                    contact = api.get_object_by_uid(uid, None)
1275
                    if not contact:
1276
                        # Invalid contact, will fail later
1277
                        break
1278
1279
                    parent = api.get_parent(contact)
1280
                    # Global contacts (not under a client) are always allowed
1281
                    if not IClient.providedBy(parent):
1282
                        continue
1283
1284
                    # Client contacts must match the query
1285
                    parent_uid = api.get_uid(parent)
1286
                    parent_uid_query = query.get("getParentUID", [])
1287
                    if parent_uid not in parent_uid_query:
1288
                        # This client contact doesn't match the query
1289
                        break
1290
                else:
1291
                    # All contacts are either global or match the query
1292
                    return {"allowed": True}
1293
1294
            # check if the current value is allowed for the new query
1295
            brains = api.search(query, catalog=catalog)
1296
            allowed_uids = list(map(api.get_uid, brains))
1297
            if set(uids).issubset(allowed_uids):
1298
                return {"allowed": True}
1299
1300
        message = {
1301
            "title": _("Field flushed"),
1302
            "text": _(u"The value of field '%s' was emptied. "
1303
                      u"Please select a new value." % api.safe_unicode(field)),
1304
        }
1305
1306
        return {
1307
            "allowed": False,
1308
            "message": message,
1309
        }
1310
1311
    def ajax_get_flush_settings(self):
1312
        """Returns the settings for fields flush
1313
1314
        NOTE: We automatically flush fields if the current value of a dependent
1315
              reference field is *not* allowed by the set new query.
1316
              -> see self.ajax_is_reference_value_allowed()
1317
              Therefore, it makes only sense for non-reference fields!
1318
        """
1319
        flush_settings = {
1320
            "Client": [
1321
            ],
1322
            "Contact": [
1323
            ],
1324
            "SampleType": [
1325
            ],
1326
            "PrimarySample": [
1327
                "EnvironmentalConditions",
1328
            ]
1329
        }
1330
1331
        # Maybe other add-ons have additional fields that require flushing
1332
        for name, ad in getAdapters((self.context,), IAddSampleFieldsFlush):
1333
            logger.info("Additional flush settings from {}".format(name))
1334
            additional_settings = ad.get_flush_settings()
1335
            for key, values in additional_settings.items():
1336
                new_values = flush_settings.get(key, []) + values
1337
                flush_settings[key] = list(set(new_values))
1338
1339
        return flush_settings
1340
1341
    def ajax_get_service(self):
1342
        """Returns the services information
1343
        """
1344
        uid = self.request.form.get("uid", None)
1345
1346
        if uid is None:
1347
            return self.error("Invalid UID", status=400)
1348
1349
        service = self.get_object_by_uid(uid)
1350
        if not service:
1351
            return self.error("Service not found", status=404)
1352
1353
        info = self.get_service_info(service)
1354
        return info
1355
1356
    def ajax_recalculate_records(self):
1357
        out = {}
1358
        records = self.get_records()
1359
        for num_sample, record in enumerate(records):
1360
            # Get reference fields metadata
1361
            metadata = self.get_record_metadata(record)
1362
1363
            # service_to_templates, template_to_services
1364
            templates_additional = self.get_template_additional_info(metadata)
1365
            metadata.update(templates_additional)
1366
1367
            # service_to_profiles, profiles_to_services
1368
            profiles_additional = self.get_profiles_additional_info(metadata)
1369
            metadata.update(profiles_additional)
1370
1371
            # services conducted beyond the holding time limit
1372
            beyond = self.get_services_beyond_holding_time(record)
1373
            metadata["beyond_holding_time"] = beyond
1374
1375
            # Set the metadata for current sample number (column)
1376
            out[num_sample] = metadata
1377
1378
        return out
1379
1380
    @viewcache.memoize
1381
    def get_services_max_holding_time(self):
1382
        """Returns a dict where the key is the uid of active services and the
1383
        value is a dict representing the maximum holding time in days, hours
1384
        and minutes. The dictionary only contains uids for services that have
1385
        a valid maximum holding time set
1386
        """
1387
        services = {}
1388
        query = {
1389
            "portal_type": "AnalysisService",
1390
            "point_of_capture": "lab",
1391
            "is_active": True,
1392
        }
1393
        brains = api.search(query, SETUP_CATALOG)
1394
        for brain in brains:
1395
            obj = api.get_object(brain)
1396
            max_holding_time = obj.getMaxHoldingTime()
1397
            if max_holding_time:
1398
                uid = api.get_uid(brain)
1399
                services[uid] = max_holding_time.copy()
1400
1401
        return services
1402
1403
    def get_services_beyond_holding_time(self, record):
1404
        """Return a list with the uids of the services that cannot be selected
1405
        because would be conducted past the holding time limit
1406
        """
1407
        # get the date to start count from
1408
        start_date = self.get_start_holding_date(record)
1409
        if not start_date:
1410
            return []
1411
1412
        # get the timezone of the start date for correct comparisons
1413
        tz = dtime.get_timezone(start_date)
1414
1415
        uids = []
1416
1417
        # get the max holding times grouped by service uid
1418
        services = self.get_services_max_holding_time()
1419
        for uid, max_holding_time in services.items():
1420
1421
            # calculate the maximum holding date
1422
            delta = timedelta(minutes=api.to_minutes(**max_holding_time))
1423
            max_holding_date = start_date + delta
1424
1425
            # TypeError: can't compare offset-naive and offset-aware datetimes
1426
            max_date = dtime.to_ansi(max_holding_date)
1427
            now = dtime.to_ansi(dtime.now(), timezone=tz)
1428
            if now > max_date:
1429
                uids.append(uid)
1430
1431
        return uids
1432
1433
    def get_start_holding_date(self, record):
1434
        """Returns the datetime used to calculate the holding time limit,
1435
        typically the sample collection date.
1436
        """
1437
        sampled = record.get("DateSampled")
1438
        return dtime.to_dt(sampled)
1439
1440
    def get_record_metadata(self, record):
1441
        """Returns the metadata for the record passed in
1442
        """
1443
        metadata = {}
1444
        extra_fields = {}
1445
        for key, value in record.items():
1446
            metadata_key = "{}_metadata".format(key.lower())
1447
            metadata[metadata_key] = {}
1448
1449
            if not value:
1450
                continue
1451
1452
            # Get objects information (metadata)
1453
            objs_info = self.get_objects_info(record, key)
1454
            objs_uids = map(lambda obj: obj["uid"], objs_info)
1455
            metadata[metadata_key] = dict(zip(objs_uids, objs_info))
1456
1457
            # Grab 'field_values' fields to be recalculated too
1458
            for obj_info in objs_info:
1459
                field_values = obj_info.get("field_values", {})
1460
                for field_name, field_value in field_values.items():
1461
                    if not isinstance(field_value, dict):
1462
                        # this is probably a list, e.g. "Profiles" field
1463
                        continue
1464
                    uids = self.get_uids_from_record(field_value, "uid")
1465
                    if len(uids) == 1:
1466
                        extra_fields[field_name] = uids[0]
1467
1468
        # Populate metadata with object info from extra fields (hidden fields)
1469
        for field_name, uid in extra_fields.items():
1470
            key = "{}_metadata".format(field_name.lower())
1471
            if metadata.get(key):
1472
                # This object has been processed already, skip
1473
                continue
1474
            obj = self.get_object_by_uid(uid)
1475
            if not obj:
1476
                continue
1477
            obj_info = self.get_object_info(
1478
                obj, field_name, record=extra_fields)
1479
            if not obj_info or "uid" not in obj_info:
1480
                continue
1481
            metadata[key] = {obj_info["uid"]: obj_info}
1482
1483
        return copy.deepcopy(metadata)
1484
1485
    def get_template_additional_info(self, metadata):
1486
        template_to_services = {}
1487
        service_to_templates = {}
1488
        service_metadata = metadata.get("service_metadata", {})
1489
        template = metadata.get("template_metadata", {})
1490
        # We don't expect more than one template, but who knows about future?
1491
        for uid, obj_info in template.items():
1492
            obj = self.get_object_by_uid(uid)
1493
            # get the template services
1494
            # [{'part_id': 'part-1', 'uid': '...'},
1495
            # {'part_id': 'part-1', 'uid': '...'}]
1496
            services = obj.getRawServices() or []
1497
            # get all UIDs of the template records
1498
            service_uids = map(lambda rec: rec.get("uid"), services)
1499
            # remember a mapping of template uid -> service
1500
            template_to_services[uid] = service_uids
1501
            # remember a mapping of service uid -> templates
1502
            for service_uid in service_uids:
1503
                # remember the template of all services
1504
                if service_uid in service_to_templates:
1505
                    service_to_templates[service_uid].append(uid)
1506
                else:
1507
                    service_to_templates[service_uid] = [uid]
1508
                # remember the service metadata
1509
                if service_uid not in service_metadata:
1510
                    service = self.get_object_by_uid(service_uid)
1511
                    service_info = self.get_service_info(service)
1512
                    service_metadata[service_uid] = service_info
1513
1514
        return {
1515
            "service_to_templates": service_to_templates,
1516
            "template_to_services": template_to_services,
1517
            "service_metadata": service_metadata,
1518
        }
1519
1520
    def get_profiles_additional_info(self, metadata):
1521
        profile_to_services = {}
1522
        service_to_profiles = metadata.get("service_to_profiles", {})
1523
        service_metadata = metadata.get("service_metadata", {})
1524
        profiles = metadata.get("profiles_metadata", {})
1525
        for uid, obj_info in profiles.items():
1526
            obj = self.get_object_by_uid(uid)
1527
            # get all services of this profile
1528
            services = obj.getServices()
1529
            # get all UIDs of the profile services
1530
            service_uids = map(api.get_uid, services)
1531
            # remember all services of this profile
1532
            profile_to_services[uid] = service_uids
1533
            # remember a mapping of service uid -> profiles
1534
            for service in services:
1535
                # get the UID of this service
1536
                service_uid = api.get_uid(service)
1537
                # remember the profiles of this service
1538
                if service_uid in service_to_profiles:
1539
                    service_to_profiles[service_uid].append(uid)
1540
                else:
1541
                    service_to_profiles[service_uid] = [uid]
1542
                # remember the service metadata
1543
                if service_uid not in service_metadata:
1544
                    service_info = self.get_service_info(service)
1545
                    service_metadata[service_uid] = service_info
1546
1547
        return {
1548
            "profile_to_services": profile_to_services,
1549
            "service_to_profiles": service_to_profiles,
1550
            "service_metadata": service_metadata,
1551
        }
1552
1553
    def get_objects_info(self, record, key):
1554
        """
1555
        Returns a list with the metadata for the objects the field with
1556
        field_name passed in refers to. Returns empty list if the field is not
1557
        a reference field or the record for this key cannot be handled
1558
        :param record: a record for a single sample (column)
1559
        :param key: The key of the field from the record (e.g. Client_uid)
1560
        :return: list of info objects
1561
        """
1562
        # Get the objects from this record. Returns a list because the field
1563
        # can be multivalued
1564
        uids = self.get_uids_from_record(record, key)
1565
        objects = map(self.get_object_by_uid, uids)
1566
        objects = map(lambda obj: self.get_object_info(
1567
            obj, key, record=record), objects)
1568
        return filter(None, objects)
1569
1570
    def get_object_info(self, obj, key, record=None):
1571
        """Returns the object info metadata for the passed in object and key
1572
        :param obj: the object from which extract the info from
1573
        :param key: The key of the field from the record (e.g. Client_uid)
1574
        :return: dict that represents the object
1575
        """
1576
        # Check if there is a function to handle objects for this field
1577
        field_name = key
1578
        func_name = "get_{}_info".format(field_name.lower())
1579
        func = getattr(self, func_name, None)
1580
1581
        # always ensure we have a record
1582
        if record is None:
1583
            record = {}
1584
1585
        # Get the info for each object
1586
        info = callable(func) and func(obj) or self.get_base_info(obj)
1587
1588
        # update query filters based on record values
1589
        func_name = "get_{}_queries".format(field_name.lower())
1590
        func = getattr(self, func_name, None)
1591
        if callable(func):
1592
            info["filter_queries"] = func(obj, record)
1593
1594
        # Check if there is any adapter to handle objects for this field
1595
        for name, adapter in getAdapters((obj, ), IAddSampleObjectInfo):
1596
            logger.info("adapter for '{}': {}".format(field_name, name))
1597
            ad_info = adapter.get_object_info_with_record(record)
1598
            self.update_object_info(info, ad_info)
1599
1600
        return info
1601
1602
    def get_client_queries(self, obj, record=None):
1603
        """Returns the filter queries to be applied to other fields based on
1604
        both the Client object and record
1605
        """
1606
        # UID of the client
1607
        uid = api.get_uid(obj)
1608
1609
        # catalog queries for UI field filtering
1610
        queries = {
1611
            "Contact": {
1612
                "getParentUID": [uid, ""]
1613
            },
1614
            "CCContact": {
1615
                "getParentUID": [uid, ""]
1616
            },
1617
            "SamplePoint": {
1618
                "getClientUID": [uid, ""],
1619
            },
1620
            "Template": {
1621
                "getClientUID": [uid, ""],
1622
            },
1623
            "Profiles": {
1624
                "getClientUID": [uid, ""],
1625
            },
1626
            "Specification": {
1627
                "getClientUID": [uid, ""],
1628
            },
1629
            "Sample": {
1630
                "getClientUID": [uid],
1631
            },
1632
            "Batch": {
1633
                "getClientUID": [uid, ""],
1634
            },
1635
            "PrimaryAnalysisRequest": {
1636
                "getClientUID": [uid, ""],
1637
            }
1638
        }
1639
1640
        # additional filtering by sample type
1641
        record = record if record else {}
1642
        sample_type_uid = record.get("SampleType")
1643
        if api.is_uid(sample_type_uid):
1644
            st_queries = self.get_sampletype_queries(sample_type_uid, record)
1645
            queries.update(st_queries)
1646
1647
        return queries
1648
1649
    def get_sampletype_queries(self, obj, record=None):
1650
        """Returns the filter queries to apply to other fields based on both
1651
        the SampleType object and record
1652
        """
1653
        uid = api.get_uid(obj)
1654
        queries = {
1655
            # Display Sample Points that have this sample type assigned plus
1656
            # those that do not have a sample type assigned
1657
            "SamplePoint": {
1658
                "sampletype_uid": [uid, ""],
1659
            },
1660
            # Display Analysis Profiles that have this sample type assigned
1661
            # in addition to those that do not have a sample profile assigned
1662
            "Profiles": {
1663
                "sampletype_uid": [uid, ""],
1664
            },
1665
            # Display Specifications that have this sample type assigned only
1666
            "Specification": {
1667
                "sampletype_uid": uid,
1668
            },
1669
            # Display Sample Templates that have this sample type assigned plus
1670
            # those that do not have a sample type assigned
1671
            "Template": {
1672
                "sampletype_uid": [uid, ""],
1673
            }
1674
        }
1675
1676
        # additional filters by client
1677
        record = record if record else {}
1678
        client = record.get("Client") or self.get_client()
1679
        client_uid = api.get_uid(client) if client else None
1680
        if client_uid:
1681
            fields = ["Template", "Specification", "Profiles", "SamplePoint"]
1682
            for field in fields:
1683
                queries[field]["getClientUID"] = [client_uid, ""]
1684
1685
        return queries
1686
1687
    def update_object_info(self, base_info, additional_info):
1688
        """Updates the dictionaries for keys 'field_values' and 'filter_queries'
1689
        from base_info with those defined in additional_info. If base_info is
1690
        empty or None, updates the whole base_info dict with additional_info
1691
        """
1692
        if not base_info:
1693
            base_info.update(additional_info)
1694
            return
1695
1696
        # Merge field_values info
1697
        field_values = base_info.get("field_values", {})
1698
        field_values.update(additional_info.get("field_values", {}))
1699
        base_info["field_values"] = field_values
1700
1701
        # Merge filter_queries info
1702
        filter_queries = base_info.get("filter_queries", {})
1703
        filter_queries.update(additional_info.get("filter_queries", {}))
1704
        base_info["filter_queries"] = filter_queries
1705
1706
    def show_recalculate_prices(self):
1707
        setup = api.get_senaite_setup()
1708
        return setup.getShowPrices()
1709
1710
    def ajax_recalculate_prices(self):
1711
        """Recalculate prices for all ARs
1712
        """
1713
        # When the option "Include and display pricing information" in
1714
        # Bika Setup Accounting tab is not selected
1715
        if not self.show_recalculate_prices():
1716
            return {}
1717
1718
        # The sorted records from the request
1719
        records = self.get_records()
1720
1721
        client = self.get_client()
1722
        setup = api.get_senaite_setup()
1723
1724
        member_discount = float(setup.getMemberDiscount())
1725
        member_discount_applies = False
1726
        if client:
1727
            member_discount_applies = client.getMemberDiscountApplies()
1728
1729
        prices = {}
1730
        for n, record in enumerate(records):
1731
            ardiscount_amount = 0.00
1732
            arservices_price = 0.00
1733
            arprofiles_price = 0.00
1734
            arprofiles_vat_amount = 0.00
1735
            arservice_vat_amount = 0.00
1736
            services_from_priced_profile = []
1737
1738
            profile_uids = record.get("Profiles", [])
1739
            profiles = map(self.get_object_by_uid, profile_uids)
1740
            services = map(self.get_object_by_uid, record.get("Analyses", []))
1741
1742
            # ANALYSIS PROFILES PRICE
1743
            for profile in profiles:
1744
                use_profile_price = profile.getUseAnalysisProfilePrice()
1745
                if not use_profile_price:
1746
                    continue
1747
1748
                profile_price = float(profile.getAnalysisProfilePrice())
1749
                arprofiles_price += profile_price
1750
                arprofiles_vat_amount += profile.getVATAmount()
1751
                profile_services = profile.getServices()
1752
                services_from_priced_profile.extend(profile_services)
1753
1754
            # ANALYSIS SERVICES PRICE
1755
            for service in services:
1756
                # skip services that are part of a priced profile
1757
                if service in services_from_priced_profile:
1758
                    continue
1759
                service_price = float(service.getPrice())
1760
                # service_vat = float(service.getVAT())
1761
                service_vat_amount = float(service.getVATAmount())
1762
                arservice_vat_amount += service_vat_amount
1763
                arservices_price += service_price
1764
1765
            base_price = arservices_price + arprofiles_price
1766
1767
            # Calculate the member discount if it applies
1768
            if member_discount and member_discount_applies:
1769
                logger.info("Member discount applies with {}%".format(
1770
                    member_discount))
1771
                ardiscount_amount = base_price * member_discount / 100
1772
1773
            subtotal = base_price - ardiscount_amount
1774
            vat_amount = arprofiles_vat_amount + arservice_vat_amount
1775
            total = subtotal + vat_amount
1776
1777
            prices[n] = {
1778
                "discount": "{0:.2f}".format(ardiscount_amount),
1779
                "subtotal": "{0:.2f}".format(subtotal),
1780
                "vat": "{0:.2f}".format(vat_amount),
1781
                "total": "{0:.2f}".format(total),
1782
            }
1783
            logger.info("Prices for AR {}: Discount={discount} "
1784
                        "VAT={vat} Subtotal={subtotal} total={total}"
1785
                        .format(n, **prices[n]))
1786
1787
        return prices
1788
1789
    def get_field(self, field_name):
1790
        """Returns the field from the temporary sample with the given name
1791
        """
1792
        if IField.providedBy(field_name):
1793
            return field_name
1794
1795
        for field in self.get_ar_fields():
1796
            if field.getName() == field_name:
1797
                return field
1798
        return None
1799
1800
    def get_field_label(self, field):
1801
        """Returns the translated label of the given field
1802
        """
1803
        field = self.get_field(field)
1804
        if not field:
1805
            return ""
1806
1807
        instance = self.get_ar()
1808
        label = field.widget.Label(instance)
1809
        if isinstance(label, Message):
1810
            return self.context.translate(label)
1811
        return label
1812
1813
    def check_confirmation(self):
1814
        """Returns a dict when user confirmation is required for the creation of
1815
        samples. Returns None otherwise
1816
        """
1817
        if self.request.form.get("confirmed") == "1":
1818
            # User pressed the "yes" button in the confirmation pane already
1819
            return None
1820
1821
        # Find out if there is a confirmation adapter available
1822
        adapter = queryAdapter(self.request, IAddSampleConfirmation)
1823
        if not adapter:
1824
            return None
1825
1826
        # Extract records from the request and call the adapter
1827
        records = self.get_records()
1828
        return adapter.check_confirmation(records)
1829
1830
    def ajax_cancel(self):
1831
        """Cancel and redirect to configured actions
1832
        """
1833
        message = _("Sample creation cancelled")
1834
        self.context.plone_utils.addPortalMessage(message, "info")
1835
        return self.handle_redirect([], message)
1836
1837
    def ajax_submit(self):
1838
        """Create samples and redirect to configured actions
1839
        """
1840
        # Check if there is the need to display a confirmation pane
1841
        confirmation = self.check_confirmation()
1842
        if confirmation:
1843
            return {"confirmation": confirmation}
1844
1845
        # Get the maximum number of samples to create per record
1846
        max_samples_record = self.get_max_samples_per_record()
1847
1848
        # Get AR required fields (including extended fields)
1849
        fields = self.get_ar_fields()
1850
        required_keys = [field.getName() for field in fields if field.required]
1851
1852
        # extract records from request
1853
        records = self.get_records()
1854
1855
        fielderrors = {}
1856
        errors = {"message": "", "fielderrors": {}}
1857
1858
        valid_records = []
1859
1860
        # Validate required fields
1861
        for num, record in enumerate(records):
1862
1863
            # Extract file uploads (fields ending with _file)
1864
            # These files will be added later as attachments
1865
            file_fields = filter(lambda f: f.endswith("_file"), record)
1866
            uploads = map(lambda f: record.pop(f), file_fields)
0 ignored issues
show
introduced by
The variable record does not seem to be defined for all execution paths.
Loading history...
1867
            attachments = [self.to_attachment_record(f) for f in uploads]
1868
1869
            # Required fields and their values
1870
            required_values = [record.get(key) for key in required_keys]
1871
            required_fields = dict(zip(required_keys, required_values))
1872
1873
            # Client field is required but hidden in the AR Add form. We remove
1874
            # it therefore from the list of required fields to let empty
1875
            # columns pass the required check below.
1876
            if record.get("Client", False):
1877
                required_fields.pop("Client", None)
1878
1879
            # Check if analyses are required for sample registration
1880
            if not self.analyses_required():
1881
                required_fields.pop("Analyses", None)
1882
1883
            # Contacts get pre-filled out if only one contact exists.
1884
            # We won't force those columns with only the Contact filled out to
1885
            # be required.
1886
            contact = required_fields.pop("Contact", None)
1887
1888
            # None of the required fields are filled, skip this record
1889
            if not any(required_fields.values()):
1890
                continue
1891
1892
            # Re-add the Contact
1893
            required_fields["Contact"] = contact
1894
1895
            # Check if the contact belongs to the selected client or is global
1896
            contact_obj = api.get_object(contact, None)
1897
            if not contact_obj:
1898
                fielderrors["Contact"] = _("No valid contact")
1899
            else:
1900
                parent = api.get_parent(contact_obj)
1901
                parent_uid = api.get_uid(parent)
1902
                # Allow contacts that belong to the client or are global
1903
                from bika.lims.interfaces import IClient
1904
                is_client_contact = parent_uid == record.get("Client")
1905
                is_global_contact = not IClient.providedBy(parent)
1906
                if not (is_client_contact or is_global_contact):
1907
                    msg = _("Contact does not belong to the selected client")
1908
                    fielderrors["Contact"] = msg
1909
1910
            # Check if the number of samples per record is permitted
1911
            num_samples = self.get_num_samples(record)
1912
            if num_samples > max_samples_record:
1913
                msg = _(u"error_analyssirequest_numsamples_above_max",
1914
                        u"The number of samples to create for the record "
1915
                        u"'Sample ${record_index}' (${num_samples}) is above "
1916
                        u"${max_num_samples}",
1917
                        mapping={
1918
                            "record_index": num+1,
1919
                            "num_samples": num_samples,
1920
                            "max_num_samples": max_samples_record,
1921
                        })
1922
                fielderrors["NumSamples"] = self.context.translate(msg)
1923
1924
            # Missing required fields
1925
            missing = [f for f in required_fields if not record.get(f, None)]
1926
1927
            # Handle fields from Service conditions
1928
            for condition in record.get("ServiceConditions", []):
1929
                if condition.get("type") == "file":
1930
                    # Add the file as an attachment
1931
                    file_upload = condition.get("value")
1932
                    att = self.to_attachment_record(file_upload)
1933
                    if att:
1934
                        # Add the file as an attachment
1935
                        att.update({
1936
                            "Service": condition.get("uid"),
1937
                            "Condition": condition.get("title"),
1938
                        })
1939
                        attachments.append(att)
1940
                    # Reset the condition value
1941
                    filename = file_upload and file_upload.filename or ""
1942
                    condition.value = filename
1943
1944
                if condition.get("required") == "on":
1945
                    if not condition.get("value"):
1946
                        title = condition.get("title")
1947
                        if title not in missing:
1948
                            missing.append(title)
1949
1950
            # If there are required fields missing, flag an error
1951
            for field in missing:
1952
                fieldname = "{}-{}".format(field, num)
1953
                label = self.get_field_label(field) or field
1954
                msg = self.context.translate(_("Field '{}' is required"))
1955
                fielderrors[fieldname] = msg.format(label)
1956
1957
            # Process and validate field values
1958
            valid_record = dict()
1959
            tmp_sample = self.get_ar()
1960
            for field in fields:
1961
                field_name = field.getName()
1962
                field_value = record.get(field_name)
1963
                if field_value in ['', None]:
1964
                    continue
1965
1966
                # process the value as the widget would usually do
1967
                process_value = field.widget.process_form
1968
                value, msgs = process_value(tmp_sample, field, record)
1969
                if not value:
1970
                    continue
1971
1972
                # store the processed value as the valid record
1973
                valid_record[field_name] = value
1974
1975
                # validate the value
1976
                error = field.validate(value, tmp_sample)
1977
                if error:
1978
                    field_name = "{}-{}".format(field_name, num)
1979
                    fielderrors[field_name] = error
1980
1981
            # add the attachments to the record
1982
            valid_record["attachments"] = filter(None, attachments)
1983
1984
            # keep the `_source_uid` in the record for the create process
1985
            valid_record["_source_uid"] = record.get("_source_uid")
1986
1987
            # append the valid record to the list of valid records
1988
            valid_records.append(valid_record)
1989
1990
        # return immediately with an error response if some field checks failed
1991
        if fielderrors:
1992
            errors["fielderrors"] = fielderrors
1993
            return {'errors': errors}
1994
1995
        # do a custom validation of records. For instance, we may want to rise
1996
        # an error if a value set to a given field is not consistent with a
1997
        # value set to another field
1998
        validators = getAdapters((self.request, ), IAddSampleRecordsValidator)
1999
        for name, validator in validators:
2000
            validation_err = validator.validate(valid_records)
2001
            if validation_err:
2002
                # Not valid, return immediately with an error response
2003
                return {"errors": validation_err}
2004
2005
        # create the samples
2006
        try:
2007
            samples = self.create_samples(valid_records)
2008
        except Exception as e:
2009
            errors["message"] = str(e)
2010
            logger.error(e, exc_info=True)
2011
            return {"errors": errors}
2012
2013
        # We keep the title to check if AR is newly created
2014
        # and UID to print stickers
2015
        ARs = OrderedDict()
2016
        for sample in samples:
2017
            ARs[sample.Title()] = sample.UID()
2018
2019
        level = "info"
2020
        if len(ARs) == 0:
2021
            message = _('No Samples could be created.')
2022
            level = "error"
2023
        elif len(ARs) > 1:
2024
            message = _('Samples ${ARs} were successfully created.',
2025
                        mapping={'ARs': safe_unicode(', '.join(ARs.keys()))})
2026
        else:
2027
            message = _('Sample ${AR} was successfully created.',
2028
                        mapping={'AR': safe_unicode(ARs.keys()[0])})
2029
2030
        # Display a portal message
2031
        self.context.plone_utils.addPortalMessage(message, level)
2032
2033
        return self.handle_redirect(ARs.values(), message)
2034
2035
    def create_samples(self, records):
2036
        """Creates samples for the given records
2037
        """
2038
        samples = []
2039
        for record in records:
2040
            client_uid = record.get("Client")
2041
            client = self.get_object_by_uid(client_uid)
2042
            if not client:
2043
                raise ValueError("No client found")
2044
2045
            # Pop the attachments
2046
            attachments = record.pop("attachments", [])
2047
2048
            # Pop the source UID
2049
            source_uid = record.pop("_source_uid", None)
2050
2051
            # Fetch the source object
2052
            source = None
2053
            if source_uid:
2054
                source = api.get_object(source_uid)
2055
2056
            # Create as many samples as required
2057
            num_samples = self.get_num_samples(record)
2058
            for idx in range(num_samples):
2059
                sample = self.create_sample(
2060
                    client, record, attachments=attachments, source=source)
2061
                samples.append(sample)
2062
2063
        return samples
2064
2065
    def create_sample(self, client, record, attachments=None, source=None):
2066
        """Creates a single sample with proper transaction handling
2067
2068
        :param client: The client container where the sample will be created
2069
        :param record: Dict with sample data (field names to values)
2070
        :param attachments: List of attachment records to add to the sample
2071
        :param source: Source object for sample hooks (e.g., for copy/partition)
2072
        :return: The created sample object
2073
        """
2074
        # Create a savepoint before sample creation to allow proper rollback
2075
        # if sample creation fails (e.g., ID generation error)
2076
        sp = transaction.savepoint()
2077
        try:
2078
            # Create the sample
2079
            sample = crar(client, self.request, record)
2080
2081
            # Create the attachments
2082
            if attachments:
2083
                for attachment_record in attachments:
2084
                    self.create_attachment(sample, attachment_record)
2085
2086
            # Pass the new sample to all subscription hooks
2087
            hooks = subscribers((sample, self.request), IAfterCreateSampleHook)
2088
            # Lower sort keys are processed first
2089
            sorted_hooks = sorted(
2090
                hooks, key=lambda x: api.to_float(getattr(x, "sort", 10)))
2091
            for hook in sorted_hooks:
2092
                hook.update(sample, source=source)
2093
2094
            # Commit the sample creation
2095
            transaction.savepoint(optimistic=True)
2096
            return sample
2097
        except Exception:
2098
            # Roll back to the savepoint before this sample creation
2099
            # This properly reverts all changes including catalog entries,
2100
            # workflow history, annotations, etc.
2101
            sp.rollback()
2102
            raise
2103
2104
    def get_num_samples(self, record):
2105
        """Return the number of samples to create for the given record
2106
        """
2107
        num_samples = record.get("NumSamples", 1)
2108
        num_samples = api.to_int(num_samples, default=1)
2109
        return num_samples if num_samples > 0 else 1
2110
2111
    @viewcache.memoize
2112
    def get_max_samples_per_record(self):
2113
        """Returns the maximum number of samples that can be created for each
2114
        record/column from the sample add form
2115
        """
2116
        setup = api.get_senaite_setup()
2117
        return setup.getMaxNumberOfSamplesAdd()
2118
2119
    def is_automatic_label_printing_enabled(self):
2120
        """Returns whether the automatic printing of barcode labels is active
2121
        """
2122
        setup = api.get_senaite_setup()
2123
        auto_print = setup.getAutoPrintStickers()
2124
        auto_receive = setup.getAutoreceiveSamples()
2125
        action = "receive" if auto_receive else "register"
2126
        return action in auto_print
2127
2128
    def handle_redirect(self, uids, message):
2129
        """Handle redirect after sample creation or cancel
2130
        """
2131
        # Automatic label printing
2132
        setup = api.get_senaite_setup()
2133
        auto_print = self.is_automatic_label_printing_enabled()
2134
        # Check if immediate results entry is enabled in setup and the current
2135
        # user has enough privileges to do so
2136
        multi_results = setup.getImmediateResultsEntry() and check_permission(
2137
            TransitionMultiResults, self.context)
2138
        redirect_to = self.context.absolute_url()
2139
2140
        # UIDs of the new created samples
2141
        sample_uids = ",".join(uids)
2142
        # UIDs of previous created samples when save&copy was selected
2143
        prev_sample_uids = self.request.get("sample_uids")
2144
        if prev_sample_uids:
2145
            sample_uids = ",".join([prev_sample_uids, sample_uids])
2146
        # Get the submit action (either "Save" or "Save and Copy")
2147
        submit_action = self.request.form.get("submit_action", "save")
2148
        if submit_action == "save_and_copy":
2149
            # redirect to the sample add form, but keep track of
2150
            # previous created sample UIDs
2151
            redirect_to = "{}/ar_add?copy_from={}&ar_count={}&sample_uids={}" \
2152
                .format(self.context.absolute_url(),
2153
                        ",".join(uids),  # copy_from
2154
                        len(uids),  # ar_count
2155
                        sample_uids)  # sample_uids
2156
        elif auto_print and sample_uids:
2157
            redirect_to = "{}/sticker?autoprint=1&items={}".format(
2158
                self.context.absolute_url(), sample_uids)
2159
        elif multi_results and sample_uids:
2160
            redirect_to = "{}/multi_results?uids={}".format(
2161
                self.context.absolute_url(),
2162
                sample_uids)
2163
        return {
2164
            "success": message,
2165
            "redirect_to": redirect_to,
2166
        }
2167
2168
    def get_json(self, encoding="utf8"):
2169
        """Extracts the JSON from the request
2170
        """
2171
        body = self.request.get("BODY", "{}")
2172
2173
        def encode_hook(pairs):
2174
            """This hook is called for dicitionaries on JSON deserialization
2175
2176
            It is used to encode unicode strings with the given encoding,
2177
            because ZCatalogs have sometimes issues with unicode queries.
2178
            """
2179
            new_pairs = []
2180
            for key, value in pairs.iteritems():
2181
                # Encode the key
2182
                if isinstance(key, six.string_types):
2183
                    key = key.encode(encoding)
2184
                # Encode the value
2185
                if isinstance(value, six.string_types):
2186
                    value = value.encode(encoding)
2187
                new_pairs.append((key, value))
2188
            return dict(new_pairs)
2189
2190
        return json.loads(body, object_hook=encode_hook)
2191