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