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-2021 by it's authors. |
19
|
|
|
# Some rights reserved, see README and LICENSE. |
20
|
|
|
|
21
|
|
|
import collections |
22
|
|
|
|
23
|
|
|
from bika.lims import api |
24
|
|
|
from bika.lims import bikaMessageFactory as _ |
25
|
|
|
from bika.lims.api import analysisservice as serviceapi |
26
|
|
|
from bika.lims.api.security import check_permission |
27
|
|
|
from bika.lims.browser.bika_listing import BikaListingView |
28
|
|
|
from bika.lims.config import PROJECTNAME |
29
|
|
|
from bika.lims.interfaces import IAnalysisServices |
30
|
|
|
from bika.lims.permissions import AddAnalysisService |
31
|
|
|
from bika.lims.utils import format_supsub |
32
|
|
|
from bika.lims.utils import get_image |
33
|
|
|
from bika.lims.utils import get_link |
34
|
|
|
from plone.app.content.browser.interfaces import IFolderContentsView |
35
|
|
|
from plone.app.folder.folder import ATFolder |
36
|
|
|
from plone.app.folder.folder import ATFolderSchema |
37
|
|
|
from plone.app.layout.globals.interfaces import IViewView |
38
|
|
|
from Products.Archetypes import atapi |
39
|
|
|
from Products.ATContentTypes.content.schemata import finalizeATCTSchema |
40
|
|
|
from Products.CMFCore.permissions import ModifyPortalContent |
41
|
|
|
from Products.CMFCore.utils import getToolByName |
42
|
|
|
from Products.CMFPlone.utils import safe_unicode |
43
|
|
|
from Products.Five.browser import BrowserView |
44
|
|
|
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile |
45
|
|
|
from senaite.core.interfaces import IHideActionsMenu |
46
|
|
|
from transaction import savepoint |
47
|
|
|
from zope.i18n.locales import locales |
48
|
|
|
from zope.interface.declarations import implements |
49
|
|
|
|
50
|
|
|
|
51
|
|
|
class AnalysisServiceCopy(BrowserView): |
52
|
|
|
template = ViewPageTemplateFile("templates/analysisservice_copy.pt") |
53
|
|
|
created = [] |
54
|
|
|
|
55
|
|
|
def copy_service(self, source, title, keyword): |
56
|
|
|
"""Creates a copy of the given AnalysisService object, but with the |
57
|
|
|
given title and keyword |
58
|
|
|
""" |
59
|
|
|
# Validate the keyword |
60
|
|
|
err_msg = serviceapi.check_keyword(keyword) |
61
|
|
|
if err_msg: |
62
|
|
|
self.context.plone_utils.addPortalMessage( |
63
|
|
|
safe_unicode(err_msg), "error") |
64
|
|
|
return |
65
|
|
|
|
66
|
|
|
# Create a copy |
67
|
|
|
return api.copy_object(source, title=title, Keyword=keyword, |
68
|
|
|
ShortTitle="") |
69
|
|
|
|
70
|
|
|
def __call__(self): |
71
|
|
|
uc = getToolByName(self.context, "uid_catalog") |
72
|
|
|
if "copy_form_submitted" not in self.request: |
73
|
|
|
uids = self.request.form.get("uids", []) |
74
|
|
|
self.services = [] |
75
|
|
|
for uid in uids: |
76
|
|
|
proxies = uc(UID=uid) |
77
|
|
|
if proxies: |
78
|
|
|
self.services.append(proxies[0].getObject()) |
79
|
|
|
return self.template() |
80
|
|
|
else: |
81
|
|
|
self.savepoint = savepoint() |
82
|
|
|
sources = self.request.form.get("uids", []) |
83
|
|
|
titles = self.request.form.get("dst_title", []) |
84
|
|
|
keywords = self.request.form.get("dst_keyword", []) |
85
|
|
|
self.created = [] |
86
|
|
|
for i, s in enumerate(sources): |
87
|
|
|
if not titles[i]: |
88
|
|
|
message = _("Validation failed: title is required") |
89
|
|
|
message = safe_unicode(message) |
90
|
|
|
self.context.plone_utils.addPortalMessage(message, "info") |
91
|
|
|
self.savepoint.rollback() |
92
|
|
|
self.created = [] |
93
|
|
|
break |
94
|
|
|
if not keywords[i]: |
95
|
|
|
message = _("Validation failed: keyword is required") |
96
|
|
|
message = safe_unicode(message) |
97
|
|
|
self.context.plone_utils.addPortalMessage(message, "info") |
98
|
|
|
self.savepoint.rollback() |
99
|
|
|
self.created = [] |
100
|
|
|
break |
101
|
|
|
service_copy = self.copy_service(s, titles[i], keywords[i]) |
102
|
|
|
if service_copy: |
103
|
|
|
self.created.append(api.get_title(service_copy)) |
104
|
|
|
if len(self.created) > 1: |
105
|
|
|
message = _("${items} were successfully created.", |
106
|
|
|
mapping={ |
107
|
|
|
"items": safe_unicode( |
108
|
|
|
", ".join(self.created))}) |
109
|
|
|
elif len(self.created) == 1: |
110
|
|
|
message = _("${item} was successfully created.", |
111
|
|
|
mapping={ |
112
|
|
|
"item": safe_unicode(self.created[0])}) |
113
|
|
|
else: |
114
|
|
|
message = _("No new items were created.") |
115
|
|
|
self.context.plone_utils.addPortalMessage(message, "info") |
116
|
|
|
self.request.response.redirect(self.context.absolute_url()) |
117
|
|
|
|
118
|
|
|
|
119
|
|
|
class AnalysisServicesView(BikaListingView): |
120
|
|
|
"""Listing table view for Analysis Services |
121
|
|
|
""" |
122
|
|
|
implements(IFolderContentsView, IViewView) |
123
|
|
|
|
124
|
|
|
def __init__(self, context, request): |
125
|
|
|
super(AnalysisServicesView, self).__init__(context, request) |
126
|
|
|
|
127
|
|
|
self.an_cats = None |
128
|
|
|
self.an_cats_order = None |
129
|
|
|
self.catalog = "senaite_catalog_setup" |
130
|
|
|
|
131
|
|
|
self.contentFilter = { |
132
|
|
|
"portal_type": "AnalysisService", |
133
|
|
|
"sort_on": "sortable_title", |
134
|
|
|
"sort_order": "ascending", |
135
|
|
|
} |
136
|
|
|
|
137
|
|
|
self.context_actions = { |
138
|
|
|
_("Add"): { |
139
|
|
|
"url": "createObject?type_name=AnalysisService", |
140
|
|
|
"permission": AddAnalysisService, |
141
|
|
|
"icon": "++resource++bika.lims.images/add.png"} |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
self.icon = "{}/{}".format( |
145
|
|
|
self.portal_url, |
146
|
|
|
"/++resource++bika.lims.images/analysisservice_big.png" |
147
|
|
|
) |
148
|
|
|
|
149
|
|
|
self.title = self.context.translate(_("Analysis Services")) |
150
|
|
|
self.form_id = "list_analysisservices" |
151
|
|
|
|
152
|
|
|
self.show_select_row = False |
153
|
|
|
self.show_select_column = True |
154
|
|
|
self.show_select_all_checkbox = False |
155
|
|
|
self.pagesize = 25 |
156
|
|
|
self.sort_on = "sortable_title" |
157
|
|
|
self.categories = [] |
158
|
|
|
self.do_cats = self.context.bika_setup.getCategoriseAnalysisServices() |
159
|
|
|
self.can_sort = not self.do_cats |
160
|
|
|
self.currency_symbol = self.get_currency_symbol() |
161
|
|
|
self.decimal_mark = self.get_decimal_mark() |
162
|
|
|
if self.do_cats: |
163
|
|
|
self.pagesize = 999999 # hide batching controls |
164
|
|
|
self.show_categories = True |
165
|
|
|
self.expand_all_categories = False |
166
|
|
|
|
167
|
|
|
self.columns = collections.OrderedDict(( |
168
|
|
|
("Title", { |
169
|
|
|
"title": _("Service"), |
170
|
|
|
"index": "sortable_title", |
171
|
|
|
"replace_url": "getURL", |
172
|
|
|
"sortable": self.can_sort}), |
173
|
|
|
("Keyword", { |
174
|
|
|
"title": _("Keyword"), |
175
|
|
|
"index": "getKeyword", |
176
|
|
|
"attr": "getKeyword", |
177
|
|
|
"sortable": self.can_sort}), |
178
|
|
|
("Category", { |
179
|
|
|
"title": _("Category"), |
180
|
|
|
"attr": "getCategoryTitle", |
181
|
|
|
"sortable": self.can_sort}), |
182
|
|
|
("Methods", { |
183
|
|
|
"title": _("Methods"), |
184
|
|
|
"sortable": self.can_sort}), |
185
|
|
|
("Department", { |
186
|
|
|
"title": _("Department"), |
187
|
|
|
"toggle": False, |
188
|
|
|
"sortable": self.can_sort}), |
189
|
|
|
("Unit", { |
190
|
|
|
"title": _("Unit"), |
191
|
|
|
"sortable": False}), |
192
|
|
|
("Price", { |
193
|
|
|
"title": _("Price"), |
194
|
|
|
"sortable": self.can_sort}), |
195
|
|
|
("MaxTimeAllowed", { |
196
|
|
|
"title": _("Max Time"), |
197
|
|
|
"toggle": False, |
198
|
|
|
"sortable": self.can_sort}), |
199
|
|
|
("DuplicateVariation", { |
200
|
|
|
"title": _("Dup Var"), |
201
|
|
|
"toggle": False, |
202
|
|
|
"sortable": False}), |
203
|
|
|
("Calculation", { |
204
|
|
|
"title": _("Calculation"), |
205
|
|
|
"sortable": False}), |
206
|
|
|
("SortKey", { |
207
|
|
|
"ajax": True, |
208
|
|
|
"help": _( |
209
|
|
|
"Float value from 0.0 - 1000.0 indicating the sort order. " |
210
|
|
|
"Duplicate values are ordered alphabetically."), |
211
|
|
|
"refetch": True, |
212
|
|
|
"title": _("Sort Key"), |
213
|
|
|
"sortable": False}), |
214
|
|
|
)) |
215
|
|
|
|
216
|
|
|
copy_transition = { |
217
|
|
|
"id": "duplicate", |
218
|
|
|
"title": _("Duplicate"), |
219
|
|
|
"url": "{}/copy".format(api.get_url(self.context)) |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
self.review_states = [ |
223
|
|
|
{ |
224
|
|
|
"id": "default", |
225
|
|
|
"title": _("Active"), |
226
|
|
|
"contentFilter": {"review_state": "active"}, |
227
|
|
|
"columns": self.columns.keys(), |
228
|
|
|
"custom_transitions": [copy_transition] |
229
|
|
|
}, { |
230
|
|
|
"id": "inactive", |
231
|
|
|
"title": _("Inactive"), |
232
|
|
|
"contentFilter": {"review_state": "inactive"}, |
233
|
|
|
"columns": self.columns.keys(), |
234
|
|
|
"custom_transitions": [copy_transition] |
235
|
|
|
}, { |
236
|
|
|
"id": "all", |
237
|
|
|
"title": _("All"), |
238
|
|
|
"contentFilter": {}, |
239
|
|
|
"columns": self.columns.keys(), |
240
|
|
|
"custom_transitions": [copy_transition] |
241
|
|
|
}, |
242
|
|
|
] |
243
|
|
|
|
244
|
|
|
if not self.context.bika_setup.getShowPrices(): |
245
|
|
|
for i in range(len(self.review_states)): |
246
|
|
|
self.review_states[i]["columns"].remove("Price") |
247
|
|
|
|
248
|
|
|
def can_edit(self, service): |
249
|
|
|
"""Check if manage is allowed |
250
|
|
|
""" |
251
|
|
|
return check_permission(ModifyPortalContent, self.context) |
252
|
|
|
|
253
|
|
|
def get_decimal_mark(self): |
254
|
|
|
"""Returns the decimal mark |
255
|
|
|
""" |
256
|
|
|
return self.context.bika_setup.getDecimalMark() |
257
|
|
|
|
258
|
|
|
def get_currency_symbol(self): |
259
|
|
|
"""Returns the locale currency symbol |
260
|
|
|
""" |
261
|
|
|
currency = self.context.bika_setup.getCurrency() |
262
|
|
|
locale = locales.getLocale("en") |
263
|
|
|
locale_currency = locale.numbers.currencies.get(currency) |
264
|
|
|
if locale_currency is None: |
265
|
|
|
return "$" |
266
|
|
|
return locale_currency.symbol |
267
|
|
|
|
268
|
|
|
def format_price(self, price): |
269
|
|
|
"""Formats the price with the set decimal mark and correct currency |
270
|
|
|
""" |
271
|
|
|
return u"{} {}{}{:02d}".format( |
272
|
|
|
self.currency_symbol, |
273
|
|
|
price[0], |
274
|
|
|
self.decimal_mark, |
275
|
|
|
price[1], |
276
|
|
|
) |
277
|
|
|
|
278
|
|
|
def format_maxtime(self, maxtime): |
279
|
|
|
"""Formats the max time record to a days, hours, minutes string |
280
|
|
|
""" |
281
|
|
|
minutes = maxtime.get("minutes", "0") |
282
|
|
|
hours = maxtime.get("hours", "0") |
283
|
|
|
days = maxtime.get("days", "0") |
284
|
|
|
# days, hours, minutes |
285
|
|
|
return u"{}: {} {}: {} {}: {}".format( |
286
|
|
|
_("days"), days, _("hours"), hours, _("minutes"), minutes) |
287
|
|
|
|
288
|
|
|
def format_duplication_variation(self, variation): |
289
|
|
|
"""Format duplicate variation |
290
|
|
|
""" |
291
|
|
|
return u"{}{}{:02d}".format( |
292
|
|
|
variation[0], |
293
|
|
|
self.decimal_mark, |
294
|
|
|
variation[1] |
295
|
|
|
) |
296
|
|
|
|
297
|
|
|
def folderitem(self, obj, item, index): |
298
|
|
|
"""Service triggered each time an item is iterated in folderitems. |
299
|
|
|
The use of this service prevents the extra-loops in child objects. |
300
|
|
|
:obj: the instance of the class to be foldered |
301
|
|
|
:item: dict containing the properties of the object to be used by |
302
|
|
|
the template |
303
|
|
|
:index: current index of the item |
304
|
|
|
""" |
305
|
|
|
obj = api.get_object(obj) |
306
|
|
|
cat = obj.getCategoryTitle() |
307
|
|
|
cat_order = self.an_cats_order.get(cat) |
308
|
|
|
if self.do_cats: |
309
|
|
|
# category groups entries |
310
|
|
|
item["category"] = cat |
311
|
|
|
if (cat, cat_order) not in self.categories: |
312
|
|
|
self.categories.append((cat, cat_order)) |
313
|
|
|
|
314
|
|
|
# Category |
315
|
|
|
category = obj.getCategory() |
316
|
|
|
if category: |
317
|
|
|
title = category.Title() |
318
|
|
|
url = api.get_url(category) |
319
|
|
|
item["Category"] = title |
320
|
|
|
item["replace"]["Category"] = get_link(url, value=title) |
321
|
|
|
|
322
|
|
|
# Calculation |
323
|
|
|
calculation = obj.getCalculation() |
324
|
|
|
if calculation: |
325
|
|
|
title = calculation.Title() |
326
|
|
|
url = api.get_url(calculation) |
327
|
|
|
item["Calculation"] = title |
328
|
|
|
item["replace"]["Calculation"] = get_link(url, value=title) |
329
|
|
|
|
330
|
|
|
# Methods |
331
|
|
|
methods = obj.getMethods() |
332
|
|
|
if methods: |
333
|
|
|
links = map( |
334
|
|
|
lambda m: get_link( |
335
|
|
|
m.absolute_url(), value=m.Title(), css_class="link"), |
336
|
|
|
methods) |
337
|
|
|
item["replace"]["Methods"] = ", ".join(links) |
338
|
|
|
|
339
|
|
|
# Max time allowed |
340
|
|
|
maxtime = obj.MaxTimeAllowed |
341
|
|
|
if maxtime: |
342
|
|
|
item["MaxTimeAllowed"] = self.format_maxtime(maxtime) |
343
|
|
|
|
344
|
|
|
# Price |
345
|
|
|
item["Price"] = self.format_price(obj.Price) |
346
|
|
|
|
347
|
|
|
# Duplicate Variation |
348
|
|
|
dup_variation = obj.DuplicateVariation |
349
|
|
|
if dup_variation: |
350
|
|
|
item["DuplicateVariation"] = self.format_duplication_variation( |
351
|
|
|
dup_variation) |
352
|
|
|
|
353
|
|
|
# Department |
354
|
|
|
department = obj.getDepartment() |
355
|
|
|
if department: |
356
|
|
|
title = api.get_title(department) |
357
|
|
|
url = api.get_url(department) |
358
|
|
|
item["replace"]["Department"] = get_link(url, title) |
359
|
|
|
|
360
|
|
|
# Unit |
361
|
|
|
unit = obj.getUnit() |
362
|
|
|
item["Unit"] = unit and format_supsub(unit) or "" |
363
|
|
|
|
364
|
|
|
# Sort key |
365
|
|
|
sortkey = obj.getSortKey() |
366
|
|
|
item["SortKey"] = sortkey |
367
|
|
|
|
368
|
|
|
if self.can_edit(obj): |
369
|
|
|
item["allow_edit"].append("SortKey") |
370
|
|
|
|
371
|
|
|
# Icons |
372
|
|
|
after_icons = "" |
373
|
|
|
if obj.getAccredited(): |
374
|
|
|
after_icons += get_image( |
375
|
|
|
"accredited.png", title=_("Accredited")) |
376
|
|
|
if obj.getAttachmentRequired(): |
377
|
|
|
after_icons += get_image( |
378
|
|
|
"attach_reqd.png", title=_("Attachment required")) |
379
|
|
|
if after_icons: |
380
|
|
|
item["after"]["Title"] = after_icons |
381
|
|
|
|
382
|
|
|
return item |
383
|
|
|
|
384
|
|
|
def folderitems(self): |
385
|
|
|
"""Sort by Categories |
386
|
|
|
""" |
387
|
|
|
bsc = getToolByName(self.context, "senaite_catalog_setup") |
388
|
|
|
self.an_cats = bsc( |
389
|
|
|
portal_type="AnalysisCategory", |
390
|
|
|
sort_on="sortable_title") |
391
|
|
|
self.an_cats_order = dict([ |
392
|
|
|
(b.Title, "{:04}".format(a)) |
393
|
|
|
for a, b in enumerate(self.an_cats)]) |
394
|
|
|
items = super(AnalysisServicesView, self).folderitems() |
395
|
|
|
if self.do_cats: |
396
|
|
|
self.categories = map(lambda x: x[0], |
397
|
|
|
sorted(self.categories, key=lambda x: x[1])) |
398
|
|
|
else: |
399
|
|
|
self.categories.sort() |
400
|
|
|
return items |
401
|
|
|
|
402
|
|
|
|
403
|
|
|
schema = ATFolderSchema.copy() |
404
|
|
|
finalizeATCTSchema(schema, folderish=True, moveDiscussion=False) |
405
|
|
|
|
406
|
|
|
|
407
|
|
|
class AnalysisServices(ATFolder): |
408
|
|
|
implements(IAnalysisServices, IHideActionsMenu) |
409
|
|
|
displayContentsTab = False |
410
|
|
|
schema = schema |
|
|
|
|
411
|
|
|
|
412
|
|
|
|
413
|
|
|
atapi.registerType(AnalysisServices, PROJECTNAME) |
414
|
|
|
|