Total Complexity | 133 |
Total Lines | 1052 |
Duplicated Lines | 2.09 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like bika.lims.browser.listing.view often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | # -*- coding: utf-8 -*- |
||
2 | |||
3 | import collections |
||
4 | import copy |
||
5 | import json |
||
6 | import re |
||
7 | import time |
||
8 | |||
9 | import DateTime |
||
10 | import Missing |
||
11 | from AccessControl import getSecurityManager |
||
12 | from ajax import AjaxListingView |
||
13 | from bika.lims import api |
||
14 | from bika.lims import bikaMessageFactory as _ |
||
15 | from bika.lims import deprecated |
||
16 | from bika.lims import logger |
||
17 | from bika.lims.interfaces import IFieldIcons |
||
18 | from bika.lims.utils import getFromString |
||
19 | from bika.lims.utils import t |
||
20 | from bika.lims.utils import to_utf8 |
||
21 | from plone.memoize import view |
||
22 | from Products.CMFCore.utils import getToolByName |
||
23 | from Products.CMFPlone.utils import safe_unicode |
||
24 | from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile |
||
25 | from zope.component import getAdapters |
||
26 | from zope.component import getMultiAdapter |
||
27 | |||
28 | |||
29 | class ListingView(AjaxListingView): |
||
30 | """Base Listing View |
||
31 | """ |
||
32 | template = ViewPageTemplateFile("templates/listing.pt") |
||
33 | |||
34 | # The title of the outer listing view |
||
35 | # see: templates/listing.pt |
||
36 | title = "" |
||
37 | |||
38 | # The description of the outer listing view |
||
39 | # see: templates/listing.pt |
||
40 | description = "" |
||
41 | |||
42 | # TODO: Refactor to viewlet |
||
43 | # Context actions rendered next to the title |
||
44 | # see: templates/listing.pt |
||
45 | context_actions = {} |
||
46 | |||
47 | # Default search catalog to be used for the content filter query |
||
48 | catalog = "portal_catalog" |
||
49 | |||
50 | # Catalog query used for the listing. It can be extended by the |
||
51 | # review_state filter |
||
52 | contentFilter = {} |
||
53 | |||
54 | # A mapping of column_key -> colum configuration |
||
55 | columns = collections.OrderedDict(( |
||
56 | ("Title", { |
||
57 | "title": _("Title"), |
||
58 | "index": "sortable_title"}), |
||
59 | ("Description", { |
||
60 | "title": _("Description"), |
||
61 | "index": "Description"}), |
||
62 | )) |
||
63 | |||
64 | # A list of dictionaries, specifying parameters for listing filter buttons. |
||
65 | # |
||
66 | # - If review_state[x]["transitions"] is defined, e.g.: |
||
67 | # "transitions": [{"id": "x"}] |
||
68 | # The possible transitions will be restricted by those defined |
||
69 | # |
||
70 | # - If review_state[x]["custom_transitions"] is defined, e.g.: |
||
71 | # "custom_transitions": [{"id": "x"}] |
||
72 | # The possible transitions will be extended by those defined. |
||
73 | review_states = [ |
||
74 | { |
||
75 | "id": "default", |
||
76 | "title": _("All"), |
||
77 | "contentFilter": {}, |
||
78 | "transitions": [], |
||
79 | "custom_transitions": [], |
||
80 | "columns": ["Title", "Descritpion"], |
||
81 | } |
||
82 | ] |
||
83 | |||
84 | # The initial/default review_state |
||
85 | default_review_state = "default" |
||
86 | |||
87 | # When rendering multiple listing tables, e.g. AR lab/field/qc tables, the |
||
88 | # form_id must be unique for each listing table. |
||
89 | form_id = "list" |
||
90 | |||
91 | # This is an override and a switch, but it does not guarantee allow_edit. |
||
92 | # This can be used to turn it off, regardless of settings in place by |
||
93 | # individual items/fields, but if it is turned on, ultimate control |
||
94 | # is still given to the individual items/fields. |
||
95 | allow_edit = True |
||
96 | |||
97 | # Defines the input name of the select checkbox. |
||
98 | # This will be probaly removed in later versions, because most of the form |
||
99 | # handlers expect the selected UIDs inside the "uids" request parameter |
||
100 | select_checkbox_name = "uids" |
||
101 | |||
102 | # Display a checkbox to select all visible rows |
||
103 | show_select_all_checkbox = True |
||
104 | |||
105 | # Display the left-most column for selecting all/individual items. |
||
106 | # Also see the "fetch_transitions_on_select" option. |
||
107 | show_select_column = False |
||
108 | |||
109 | # Automatically fetch all possible transitions for selected items. |
||
110 | fetch_transitions_on_select = True |
||
111 | |||
112 | # Allow to show/hide columns by right-clicking on the column header. |
||
113 | show_column_toggles = True |
||
114 | |||
115 | # Render items in expandable categories |
||
116 | show_categories = False |
||
117 | |||
118 | # These are the possible categories. If self.show_categories is True, only |
||
119 | # these categories which will be rendered. |
||
120 | categories = [] |
||
121 | |||
122 | # Expand all categories on load. If set to False, only categories |
||
123 | # containing selected items are expanded. |
||
124 | expand_all_categories = False |
||
125 | |||
126 | # Number of rows initially displayed. If more items are returned from the |
||
127 | # database, a paging control is displayed in the lower right corner |
||
128 | pagesize = 50 |
||
129 | |||
130 | # Override pagesize and show all items on one page |
||
131 | # XXX: Currently only used in classic folderitems method. |
||
132 | # -> Consider if it is worth to keep that funcitonality |
||
133 | show_all = False |
||
134 | |||
135 | # Manually sort catalog results on this column |
||
136 | # XXX: Currently the listing table sorts only if the catalog index exists. |
||
137 | # -> Consider if it is worth to keep that functionality |
||
138 | manual_sort_on = None |
||
139 | |||
140 | # Render the search box in the upper right corner |
||
141 | show_search = True |
||
142 | |||
143 | # Omit the outer form wrapper of the contents table, e.g. when the listing |
||
144 | # is used as an embedded widget in an edit form. |
||
145 | omit_form = False |
||
146 | |||
147 | # Toggle transition button rendering of the table footer |
||
148 | show_workflow_action_buttons = True |
||
149 | |||
150 | # Toggle the whole table footer rendering. This includes the pagination and |
||
151 | # transition buttons. |
||
152 | show_table_footer = True |
||
153 | |||
154 | def __init__(self, context, request): |
||
155 | super(ListingView, self).__init__(context, request) |
||
156 | self.context = context |
||
157 | self.request = request |
||
158 | |||
159 | # N.B. We set that here so that it can be overridden by subclasses, |
||
160 | # e.g. by the worksheet add view |
||
161 | if "path" not in self.contentFilter: |
||
162 | self.contentFilter.update(self.get_path_query()) |
||
163 | |||
164 | self.total = 0 |
||
165 | self.limit_from = 0 |
||
166 | self.show_more = False |
||
167 | self.sort_on = "sortable_title" |
||
168 | self.sort_order = "ascending" |
||
169 | |||
170 | # Internal cache for translated state titles |
||
171 | self.state_titles = {} |
||
172 | |||
173 | # TODO: Refactor to a view memoized property |
||
174 | # Internal cache for alert icons |
||
175 | self.field_icons = {} |
||
176 | |||
177 | def __call__(self): |
||
178 | """Handle request parameters and render the form |
||
179 | """ |
||
180 | logger.info(u"ListingView::__call__") |
||
181 | |||
182 | self.portal = api.get_portal() |
||
183 | self.mtool = api.get_tool("portal_membership") |
||
184 | self.workflow = api.get_tool("portal_workflow") |
||
185 | self.member = api.get_current_user() |
||
186 | self.translate = self.context.translate |
||
187 | |||
188 | # Call update hook |
||
189 | self.update() |
||
190 | |||
191 | # handle subpath calls |
||
192 | if len(self.traverse_subpath) > 0: |
||
193 | return self.handle_subpath() |
||
194 | |||
195 | # Call before render hook |
||
196 | self.before_render() |
||
197 | |||
198 | return self.template(self.context) |
||
199 | |||
200 | def update(self): |
||
201 | """Update the view state |
||
202 | """ |
||
203 | logger.info(u"ListingView::update") |
||
204 | self.limit_from = self.get_limit_from() |
||
205 | self.pagesize = self.get_pagesize() |
||
206 | |||
207 | def before_render(self): |
||
208 | """Before render hook |
||
209 | """ |
||
210 | logger.info(u"ListingView::before_render") |
||
211 | |||
212 | def contents_table(self, *args, **kwargs): |
||
213 | """Render the ReactJS enabled contents table template |
||
214 | """ |
||
215 | return self.contents_table_template() |
||
216 | |||
217 | @view.memoize |
||
218 | def has_permission(self, permission): |
||
219 | """Checks if the current context has the given permission |
||
220 | """ |
||
221 | sm = getSecurityManager() |
||
222 | return sm.checkPermission(permission, self.context) |
||
223 | |||
224 | @property |
||
225 | def review_state(self): |
||
226 | """Get workflow state of object in wf_id. |
||
227 | |||
228 | First try request: <form_id>_review_state |
||
229 | Then try 'default': self.default_review_state |
||
230 | |||
231 | :return: item from self.review_states |
||
232 | """ |
||
233 | if not self.review_states: |
||
234 | logger.error("%s.review_states is undefined." % self) |
||
235 | return None |
||
236 | # get state_id from (request or default_review_states) |
||
237 | key = "%s_review_state" % self.form_id |
||
238 | state_id = self.request.form.get(key, self.default_review_state) |
||
239 | if not state_id: |
||
240 | state_id = self.default_review_state |
||
241 | states = [r for r in self.review_states if r["id"] == state_id] |
||
242 | if not states: |
||
243 | logger.error("%s.review_states does not contain id='%s'." % |
||
244 | (self, state_id)) |
||
245 | return None |
||
246 | review_state = states[0] if states else self.review_states[0] |
||
247 | # set selected state into the request |
||
248 | self.request["%s_review_state" % self.form_id] = review_state["id"] |
||
249 | return review_state |
||
250 | |||
251 | def remove_column(self, column): |
||
252 | """Removes the column passed-in, if exists |
||
253 | |||
254 | :param column: Column key |
||
255 | :returns: True if the column was removed |
||
256 | """ |
||
257 | if column not in self.columns: |
||
258 | return False |
||
259 | |||
260 | del self.columns[column] |
||
261 | for item in self.review_states: |
||
262 | if column in item.get("columns", []): |
||
263 | item["columns"].remove(column) |
||
264 | return True |
||
265 | |||
266 | def getPOSTAction(self): |
||
267 | """This function returns a string as the value for the action attribute |
||
268 | of the form element in the template. |
||
269 | |||
270 | This method is used in bika_listing_table.pt |
||
271 | """ |
||
272 | return "workflow_action" |
||
273 | |||
274 | def get_form_id(self): |
||
275 | """Return the form id |
||
276 | |||
277 | Note: The form_id must be unique when rendering multiple listing tables |
||
278 | """ |
||
279 | return self.form_id |
||
280 | |||
281 | def get_catalog(self, default="portal_catalog"): |
||
282 | """Get the catalog tool to be used in the listing |
||
283 | |||
284 | :returns: ZCatalog tool |
||
285 | """ |
||
286 | try: |
||
287 | return api.get_tool(self.catalog) |
||
288 | except api.BikaLIMSError: |
||
289 | return api.get_tool(default) |
||
290 | |||
291 | @view.memoize |
||
292 | def get_catalog_indexes(self): |
||
293 | """Return a list of registered catalog indexes |
||
294 | """ |
||
295 | return self.get_catalog().indexes() |
||
296 | |||
297 | @view.memoize |
||
298 | def get_columns_indexes(self): |
||
299 | """Returns a list of allowed sorting indexeds |
||
300 | """ |
||
301 | columns = self.columns |
||
302 | indexes = [v["index"] for k, v in columns.items() if "index" in v] |
||
303 | return indexes |
||
304 | |||
305 | @view.memoize |
||
306 | def get_metadata_columns(self): |
||
307 | """Get a list of all metadata column names |
||
308 | |||
309 | :returns: List of catalog metadata column names |
||
310 | """ |
||
311 | catalog = self.get_catalog() |
||
312 | return catalog.schema() |
||
313 | |||
314 | @view.memoize |
||
315 | def translate_review_state(self, state, portal_type): |
||
316 | """Translates the review state to the current set language |
||
317 | |||
318 | :param state: Review state title |
||
319 | :type state: basestring |
||
320 | :returns: Translated review state title |
||
321 | """ |
||
322 | ts = api.get_tool("translation_service") |
||
323 | wf = api.get_tool("portal_workflow") |
||
324 | state_title = wf.getTitleForStateOnType(state, portal_type) |
||
325 | translated_state = ts.translate( |
||
326 | _(state_title or state), context=self.request) |
||
327 | logger.info(u"ListingView:translate_review_state: {} -> {} -> {}" |
||
328 | .format(state, state_title, translated_state)) |
||
329 | return translated_state |
||
330 | |||
331 | def metadata_to_searchable_text(self, brain, key, value): |
||
332 | """Parse the given metadata to text |
||
333 | |||
334 | :param brain: ZCatalog Brain |
||
335 | :param key: The name of the metadata column |
||
336 | :param value: The raw value of the metadata column |
||
337 | :returns: Searchable and translated unicode value or None |
||
338 | """ |
||
339 | if not value: |
||
340 | return u"" |
||
341 | if value is Missing.Value: |
||
342 | return u"" |
||
343 | if api.is_uid(value): |
||
344 | return u"" |
||
345 | if isinstance(value, (bool)): |
||
346 | return u"" |
||
347 | if isinstance(value, (list, tuple)): |
||
348 | for v in value: |
||
349 | return self.metadata_to_searchable_text(brain, key, v) |
||
350 | if isinstance(value, (dict)): |
||
351 | for k, v in value.items(): |
||
352 | return self.metadata_to_searchable_text(brain, k, v) |
||
353 | if self.is_date(value): |
||
354 | return self.to_str_date(value) |
||
355 | if "state" in key.lower(): |
||
356 | return self.translate_review_state( |
||
357 | value, api.get_portal_type(brain)) |
||
358 | if not isinstance(value, basestring): |
||
359 | value = str(value) |
||
360 | return safe_unicode(value) |
||
361 | |||
362 | def get_sort_order(self): |
||
363 | """Get the sort_order criteria from the request or view |
||
364 | """ |
||
365 | form_id = self.get_form_id() |
||
366 | allowed = ["ascending", "descending"] |
||
367 | sort_order = [self.request.form.get("{}_sort_order" |
||
368 | .format(form_id), None), |
||
369 | self.contentFilter.get("sort_order", None)] |
||
370 | sort_order = filter(lambda order: order in allowed, sort_order) |
||
371 | return sort_order and sort_order[0] or "descending" |
||
372 | |||
373 | def get_sort_on(self, default="created"): |
||
374 | """Get the sort_on criteria to be used |
||
375 | |||
376 | :param default: The default sort_on index to be used |
||
377 | :returns: valid sort_on index or None |
||
378 | """ |
||
379 | form_id = self.get_form_id() |
||
380 | key = "{}_sort_on".format(form_id) |
||
381 | |||
382 | # List of known catalog columns |
||
383 | catalog_columns = self.get_metadata_columns() |
||
384 | |||
385 | # The sort_on parameter from the request |
||
386 | sort_on = self.request.form.get(key, None) |
||
387 | # Use the index specified in the columns config |
||
388 | if sort_on in self.columns: |
||
389 | sort_on = self.columns[sort_on].get("index", sort_on) |
||
390 | |||
391 | # Return immediately if the request sort_on parameter is found in the |
||
392 | # catalog indexes |
||
393 | if self.is_valid_sort_index(sort_on): |
||
394 | return sort_on |
||
395 | |||
396 | # Flag manual sorting if the request sort_on parameter is found in the |
||
397 | # catalog metadata columns |
||
398 | if sort_on in catalog_columns: |
||
399 | self.manual_sort_on = sort_on |
||
400 | |||
401 | # The sort_on parameter from the catalog query |
||
402 | content_filter_sort_on = self.contentFilter.get("sort_on", None) |
||
403 | if self.is_valid_sort_index(content_filter_sort_on): |
||
404 | return content_filter_sort_on |
||
405 | |||
406 | # The sort_on attribute from the instance |
||
407 | instance_sort_on = self.sort_on |
||
408 | if self.is_valid_sort_index(instance_sort_on): |
||
409 | return instance_sort_on |
||
410 | |||
411 | # The default sort_on |
||
412 | if self.is_valid_sort_index(default): |
||
413 | return default |
||
414 | |||
415 | return None |
||
416 | |||
417 | def is_valid_sort_index(self, sort_on): |
||
418 | """Checks if the sort_on index is capable for a sort_ |
||
419 | |||
420 | :param sort_on: The name of the sort index |
||
421 | :returns: True if the sort index is capable for sorting |
||
422 | """ |
||
423 | # List of known catalog indexes |
||
424 | catalog_indexes = self.get_catalog_indexes() |
||
425 | if sort_on not in catalog_indexes: |
||
426 | return False |
||
427 | catalog = self.get_catalog() |
||
428 | sort_index = catalog.Indexes.get(sort_on) |
||
429 | if not hasattr(sort_index, "documentToKeyMap"): |
||
430 | return False |
||
431 | return True |
||
432 | |||
433 | def is_date(self, thing): |
||
434 | """checks if the passed in value is a date |
||
435 | |||
436 | :param thing: an arbitrary object |
||
437 | :returns: True if it can be converted to a date time object |
||
438 | """ |
||
439 | if isinstance(thing, DateTime.DateTime): |
||
440 | return True |
||
441 | return False |
||
442 | |||
443 | def to_str_date(self, date): |
||
444 | """Converts the date to a string |
||
445 | |||
446 | :param date: DateTime object or ISO date string |
||
447 | :returns: locale date string |
||
448 | """ |
||
449 | date = DateTime.DateTime(date) |
||
450 | try: |
||
451 | return date.strftime(self.date_format_long) |
||
452 | except ValueError: |
||
453 | return str(date) |
||
454 | |||
455 | def get_pagesize(self): |
||
456 | """Return the pagesize request parameter |
||
457 | """ |
||
458 | form_id = self.get_form_id() |
||
459 | pagesize = self.request.form.get(form_id + '_pagesize', self.pagesize) |
||
460 | try: |
||
461 | return int(pagesize) |
||
462 | except (ValueError, TypeError): |
||
463 | return self.pagesize |
||
464 | |||
465 | def get_limit_from(self): |
||
466 | """Return the limit_from request parameter |
||
467 | """ |
||
468 | form_id = self.get_form_id() |
||
469 | limit = self.request.form.get(form_id + '_limit_from', 0) |
||
470 | try: |
||
471 | return int(limit) |
||
472 | except (ValueError, TypeError): |
||
473 | return 0 |
||
474 | |||
475 | def get_path_query(self, context=None, level=0): |
||
476 | """Return a path query |
||
477 | |||
478 | :param context: The context to get the physical path from |
||
479 | :param level: The depth level of the search |
||
480 | :returns: Catalog path query |
||
481 | """ |
||
482 | if context is None: |
||
483 | context = self.context |
||
484 | path = api.get_path(context) |
||
485 | return { |
||
486 | "path": { |
||
487 | "query": path, |
||
488 | "level": level, |
||
489 | } |
||
490 | } |
||
491 | |||
492 | def get_item_info(self, brain_or_object): |
||
493 | """Return the data of this brain or object |
||
494 | """ |
||
495 | return { |
||
496 | "obj": brain_or_object, |
||
497 | "uid": api.get_uid(brain_or_object), |
||
498 | "url": api.get_url(brain_or_object), |
||
499 | "id": api.get_id(brain_or_object), |
||
500 | "title": api.get_title(brain_or_object), |
||
501 | "portal_type": api.get_portal_type(brain_or_object), |
||
502 | "review_state": api.get_workflow_status_of(brain_or_object), |
||
503 | } |
||
504 | |||
505 | def get_catalog_query(self, searchterm=None): |
||
506 | """Return the catalog query |
||
507 | |||
508 | :param searchterm: Additional filter value to be added to the query |
||
509 | :returns: Catalog query dictionary |
||
510 | """ |
||
511 | |||
512 | # avoid to change the original content filter |
||
513 | query = copy.deepcopy(self.contentFilter) |
||
514 | |||
515 | # contentFilter is allowed in every self.review_state. |
||
516 | for k, v in self.review_state.get("contentFilter", {}).items(): |
||
517 | query[k] = v |
||
518 | |||
519 | # set the sort_on criteria |
||
520 | sort_on = self.get_sort_on() |
||
521 | if sort_on is not None: |
||
522 | query["sort_on"] = sort_on |
||
523 | |||
524 | # set the sort_order criteria |
||
525 | query["sort_order"] = self.get_sort_order() |
||
526 | |||
527 | # # Pass the searchterm as well to the Searchable Text index |
||
528 | # if searchterm and isinstance(searchterm, basestring): |
||
529 | # query.update({"SearchableText": searchterm + "*"}) |
||
530 | |||
531 | logger.info(u"ListingView::get_catalog_query: query={}".format(query)) |
||
532 | return query |
||
533 | |||
534 | def make_regex_for(self, searchterm, ignorecase=True): |
||
535 | """Make a regular expression for the given searchterm |
||
536 | |||
537 | :param searchterm: The searchterm for the regular expression |
||
538 | :param ignorecase: Flag to compile with re.IGNORECASE |
||
539 | :returns: Compiled regular expression |
||
540 | """ |
||
541 | # searchterm comes in as a 8-bit string, e.g. 'D\xc3\xa4' |
||
542 | # but must be a unicode u'D\xe4' to match the metadata |
||
543 | searchterm = safe_unicode(searchterm) |
||
544 | if ignorecase: |
||
545 | return re.compile(searchterm, re.IGNORECASE) |
||
546 | return re.compile(searchterm) |
||
547 | |||
548 | def sort_brains(self, brains, sort_on=None): |
||
549 | """Sort the brains |
||
550 | |||
551 | :param brains: List of catalog brains |
||
552 | :param sort_on: The metadata column name to sort on |
||
553 | :returns: Manually sorted list of brains |
||
554 | """ |
||
555 | if sort_on not in self.get_metadata_columns(): |
||
556 | logger.warn( |
||
557 | "ListingView::sort_brains: '{}' not in metadata columns." |
||
558 | .format(sort_on)) |
||
559 | return brains |
||
560 | |||
561 | logger.warn( |
||
562 | "ListingView::sort_brains: Manual sorting on metadata column '{}'." |
||
563 | "Consider to add an explicit catalog index to speed up filtering." |
||
564 | .format(self.manual_sort_on)) |
||
565 | |||
566 | # calculate the sort_order |
||
567 | reverse = self.get_sort_order() == "descending" |
||
568 | |||
569 | def metadata_sort(a, b): |
||
570 | a = getattr(a, self.manual_sort_on, "") |
||
571 | b = getattr(b, self.manual_sort_on, "") |
||
572 | return cmp(safe_unicode(a), safe_unicode(b)) |
||
573 | |||
574 | return sorted(brains, cmp=metadata_sort, reverse=reverse) |
||
575 | |||
576 | def get_searchterm(self): |
||
577 | """Get the user entered search value from the request |
||
578 | |||
579 | :returns: Current search box value from the request |
||
580 | """ |
||
581 | form_id = self.get_form_id() |
||
582 | key = "{}_filter".format(form_id) |
||
583 | # we need to ensure unicode here |
||
584 | return safe_unicode(self.request.form.get(key, "")) |
||
585 | |||
586 | def metadata_search(self, catalog, query, searchterm, ignorecase=True): |
||
587 | """ Retrieves all the brains from given catalog and returns the ones |
||
588 | with at least one metadata containing the search term |
||
589 | :param catalog: catalog to search |
||
590 | :param query: |
||
591 | :param searchterm: |
||
592 | :param ignorecase: |
||
593 | :return: brains matching search result |
||
594 | """ |
||
595 | # create a catalog query |
||
596 | logger.info(u"ListingView::search: Prepare metadata query for '{}'" |
||
597 | .format(self.catalog)) |
||
598 | |||
599 | brains = catalog(query) |
||
600 | |||
601 | # Build a regular expression for the given searchterm |
||
602 | regex = self.make_regex_for(searchterm, ignorecase=ignorecase) |
||
603 | |||
604 | # Get the catalog metadata columns |
||
605 | columns = self.get_metadata_columns() |
||
606 | |||
607 | # Filter predicate to match each metadata value against the searchterm |
||
608 | def match(brain): |
||
609 | for column in columns: |
||
610 | value = getattr(brain, column, None) |
||
611 | parsed = self.metadata_to_searchable_text(brain, column, value) |
||
612 | if regex.search(parsed): |
||
613 | return True |
||
614 | return False |
||
615 | |||
616 | # Filtered brains by searchterm -> metadata match |
||
617 | return filter(match, brains) |
||
618 | |||
619 | def ng3_index_search(self, catalog, query, searchterm): |
||
620 | """Searches given catalog by query and also looks for a keyword in the |
||
621 | specific index called "listing_searchable_text" |
||
622 | |||
623 | #REMEMBER TextIndexNG indexes are the only indexes that wildcards can |
||
624 | be used in the beginning of the string. |
||
625 | http://zope.readthedocs.io/en/latest/zope2book/SearchingZCatalog.html#textindexng |
||
626 | |||
627 | :param catalog: catalog to search |
||
628 | :param query: |
||
629 | :param searchterm: a keyword to look for in "listing_searchable_text" |
||
630 | :return: brains matching the search result |
||
631 | """ |
||
632 | logger.info(u"ListingView::search: Prepare NG3 index query for '{}'" |
||
633 | .format(self.catalog)) |
||
634 | # Remove quotation mark |
||
635 | searchterm = searchterm.replace('"', '') |
||
636 | # If the keyword is not encoded in searches, TextIndexNG3 encodes by |
||
637 | # default encoding which we cannot always trust |
||
638 | searchterm = searchterm.encode("utf-8") |
||
639 | query["listing_searchable_text"] = "*" + searchterm + "*" |
||
640 | return catalog(query) |
||
641 | |||
642 | def _fetch_brains(self, idxfrom=0): |
||
643 | """Fetch the catalog results for the current listing table state |
||
644 | """ |
||
645 | |||
646 | searchterm = self.get_searchterm() |
||
647 | brains = self.search(searchterm=searchterm) |
||
648 | self.total = len(brains) |
||
649 | |||
650 | # Return a subset of results, if necessary |
||
651 | if idxfrom and len(brains) > idxfrom: |
||
652 | return brains[idxfrom:self.pagesize + idxfrom] |
||
653 | return brains[:self.pagesize] |
||
654 | |||
655 | def search(self, searchterm="", ignorecase=True): |
||
656 | """Search the catalog tool |
||
657 | |||
658 | :param searchterm: The searchterm for the regular expression |
||
659 | :param ignorecase: Flag to compile with re.IGNORECASE |
||
660 | :returns: List of catalog brains |
||
661 | """ |
||
662 | |||
663 | # TODO Append start and pagesize to return just that slice of results |
||
664 | |||
665 | # start the timer for performance checks |
||
666 | start = time.time() |
||
667 | |||
668 | # strip whitespaces off the searchterm |
||
669 | searchterm = searchterm.strip() |
||
670 | # Strip illegal characters of the searchterm |
||
671 | searchterm = searchterm.strip(u"*.!$%&/()=-+:'`´^") |
||
672 | logger.info(u"ListingView::search:searchterm='{}'".format(searchterm)) |
||
673 | |||
674 | # create a catalog query |
||
675 | logger.info(u"ListingView::search: Prepare catalog query for '{}'" |
||
676 | .format(self.catalog)) |
||
677 | query = self.get_catalog_query(searchterm=searchterm) |
||
678 | |||
679 | # search the catalog |
||
680 | catalog = api.get_tool(self.catalog) |
||
681 | |||
682 | # return the unfiltered catalog results if no searchterm |
||
683 | if not searchterm: |
||
684 | brains = catalog(query) |
||
685 | |||
686 | # check if there is ng3 index in the catalog to query by wildcards |
||
687 | elif "listing_searchable_text" in catalog.indexes(): |
||
688 | # Always expand all categories if we have a searchterm |
||
689 | self.expand_all_categories = True |
||
690 | brains = self.ng3_index_search(catalog, query, searchterm) |
||
691 | |||
692 | else: |
||
693 | self.expand_all_categories = True |
||
694 | brains = self.metadata_search( |
||
695 | catalog, query, searchterm, ignorecase) |
||
696 | |||
697 | # Sort manually? |
||
698 | if self.manual_sort_on is not None: |
||
699 | brains = self.sort_brains(brains, sort_on=self.manual_sort_on) |
||
700 | |||
701 | end = time.time() |
||
702 | logger.info(u"ListingView::search: Search for '{}' executed in " |
||
703 | u"{:.2f}s ({} matches)" |
||
704 | .format(searchterm, end - start, len(brains))) |
||
705 | return brains |
||
706 | |||
707 | def isItemAllowed(self, obj): |
||
708 | """ return if the item can be added to the items list. |
||
709 | """ |
||
710 | return True |
||
711 | |||
712 | def folderitem(self, obj, item, index): |
||
713 | """Service triggered each time an item is iterated in folderitems. |
||
714 | |||
715 | The use of this service prevents the extra-loops in child objects. |
||
716 | |||
717 | :obj: the instance of the class to be foldered |
||
718 | :item: dict containing the properties of the object to be used by |
||
719 | the template |
||
720 | :index: current index of the item |
||
721 | """ |
||
722 | return item |
||
723 | |||
724 | def folderitems(self, full_objects=False, classic=True): |
||
725 | """This function returns an array of dictionaries where each dictionary |
||
726 | contains the columns data to render the list. |
||
727 | |||
728 | No object is needed by default. We should be able to get all |
||
729 | the listing columns taking advantage of the catalog's metadata, |
||
730 | so that the listing will be much more faster. If a very specific |
||
731 | info has to be retrieve from the objects, we can define |
||
732 | full_objects as True but performance can be lowered. |
||
733 | |||
734 | :full_objects: a boolean, if True, each dictionary will contain an item |
||
735 | with the object itself. item.get('obj') will return a |
||
736 | object. Only works with the 'classic' way. |
||
737 | WARNING: :full_objects: could create a big performance hit! |
||
738 | :classic: if True, the old way folderitems works will be executed. This |
||
739 | function is mainly used to maintain the integrity with the |
||
740 | old version. |
||
741 | """ |
||
742 | # Getting a security manager instance for the current request |
||
743 | self.security_manager = getSecurityManager() |
||
744 | self.workflow = getToolByName(self.context, 'portal_workflow') |
||
745 | |||
746 | if classic: |
||
747 | return self._folderitems(full_objects) |
||
748 | |||
749 | # idx increases one unit each time an object is added to the 'items' |
||
750 | # dictionary to be returned. Note that if the item is not rendered, |
||
751 | # the idx will not increase. |
||
752 | idx = 0 |
||
753 | results = [] |
||
754 | self.show_more = False |
||
755 | brains = self._fetch_brains(self.limit_from) |
||
756 | for obj in brains: |
||
757 | # avoid creating unnecessary info for items outside the current |
||
758 | # batch; only the path is needed for the "select all" case... |
||
759 | # we only take allowed items into account |
||
760 | if idx >= self.pagesize: |
||
761 | # Maximum number of items to be shown reached! |
||
762 | self.show_more = True |
||
763 | break |
||
764 | |||
765 | # check if the item must be rendered or not (prevents from |
||
766 | |||
767 | # doing it later in folderitems) and dealing with paging |
||
768 | if not obj or not self.isItemAllowed(obj): |
||
769 | continue |
||
770 | |||
771 | # Get the css for this row in accordance with the obj's state |
||
772 | states = obj.getObjectWorkflowStates |
||
773 | if not states: |
||
774 | states = {} |
||
775 | state_class = ['state-{0}'.format(v) for v in states.values()] |
||
776 | state_class = ' '.join(state_class) |
||
777 | |||
778 | # Building the dictionary with basic items |
||
779 | results_dict = dict( |
||
780 | # To colour the list items by state |
||
781 | state_class=state_class, |
||
782 | # a list of names of fields that may be edited on this item |
||
783 | allow_edit=[], |
||
784 | # a dict where the column name works as a key and the value is |
||
785 | # the name of the field related with the column. It is used |
||
786 | # when the name given to the column and the content field it |
||
787 | # represents diverges. bika_listing_table_items.pt defines an |
||
788 | # attribute for each item, this attribute is named 'field' and |
||
789 | # the system fills it taking advantage of this dictionary or |
||
790 | # the name of the column if it isn't defined in the dict. |
||
791 | field={}, |
||
792 | # "before", "after" and replace: dictionary (key is column ID) |
||
793 | # A snippet of HTML which will be rendered |
||
794 | # before/after/instead of the table cell content. |
||
795 | before={}, # { before : "<a href=..>" } |
||
796 | after={}, |
||
797 | replace={}, |
||
798 | choices={}, |
||
799 | ) |
||
800 | |||
801 | # update with the base item info |
||
802 | results_dict.update(self.get_item_info(obj)) |
||
803 | |||
804 | # Set states and state titles |
||
805 | ptype = obj.portal_type |
||
806 | workflow = api.get_tool('portal_workflow') |
||
807 | for state_var, state in states.items(): |
||
808 | results_dict[state_var] = state |
||
809 | state_title = self.state_titles.get(state, None) |
||
810 | if not state_title: |
||
811 | state_title = workflow.getTitleForStateOnType(state, ptype) |
||
812 | if state_title: |
||
813 | self.state_titles[state] = state_title |
||
814 | if state_title and state == obj.review_state: |
||
815 | results_dict['state_title'] = _(state_title) |
||
816 | |||
817 | # extra classes for individual fields on this item |
||
818 | # { field_id : "css classes" } |
||
819 | results_dict['class'] = {} |
||
820 | |||
821 | # Search for values for all columns in obj |
||
822 | for key in self.columns.keys(): |
||
823 | # if the key is already in the results dict |
||
824 | # then we don't replace it's value |
||
825 | value = results_dict.get(key, '') |
||
826 | View Code Duplication | if not value: |
|
|
|||
827 | attrobj = getFromString(obj, key) |
||
828 | value = attrobj if attrobj else value |
||
829 | |||
830 | # Custom attribute? Inspect to set the value |
||
831 | # for the current column dynamically |
||
832 | vattr = self.columns[key].get('attr', None) |
||
833 | if vattr: |
||
834 | attrobj = getFromString(obj, vattr) |
||
835 | value = attrobj if attrobj else value |
||
836 | results_dict[key] = value |
||
837 | # Replace with an url? |
||
838 | replace_url = self.columns[key].get('replace_url', None) |
||
839 | if replace_url: |
||
840 | attrobj = getFromString(obj, replace_url) |
||
841 | if attrobj: |
||
842 | results_dict['replace'][key] = \ |
||
843 | '<a href="%s">%s</a>' % (attrobj, value) |
||
844 | # The item basics filled. Delegate additional actions to folderitem |
||
845 | # service. folderitem service is frequently overriden by child |
||
846 | # objects |
||
847 | item = self.folderitem(obj, results_dict, idx) |
||
848 | if item: |
||
849 | results.append(item) |
||
850 | idx += 1 |
||
851 | return results |
||
852 | |||
853 | @deprecated("Using bikalisting.folderitems(classic=True) is very slow") |
||
854 | def _folderitems(self, full_objects=False): |
||
855 | """WARNING: :full_objects: could create a big performance hit. |
||
856 | """ |
||
857 | # Setting up some attributes |
||
858 | plone_layout = getMultiAdapter((self.context.aq_inner, self.request), |
||
859 | name=u'plone_layout') |
||
860 | plone_utils = getToolByName(self.context.aq_inner, 'plone_utils') |
||
861 | portal_types = getToolByName(self.context.aq_inner, 'portal_types') |
||
862 | if self.request.form.get('show_all', '').lower() == 'true' \ |
||
863 | or self.show_all is True \ |
||
864 | or self.pagesize == 0: |
||
865 | show_all = True |
||
866 | else: |
||
867 | show_all = False |
||
868 | |||
869 | # idx increases one unit each time an object is added to the 'items' |
||
870 | # dictionary to be returned. Note that if the item is not rendered, |
||
871 | # the idx will not increase. |
||
872 | idx = 0 |
||
873 | results = [] |
||
874 | self.show_more = False |
||
875 | brains = self._fetch_brains(self.limit_from) |
||
876 | for obj in brains: |
||
877 | # avoid creating unnecessary info for items outside the current |
||
878 | # batch; only the path is needed for the "select all" case... |
||
879 | # we only take allowed items into account |
||
880 | if not show_all and idx >= self.pagesize: |
||
881 | # Maximum number of items to be shown reached! |
||
882 | self.show_more = True |
||
883 | break |
||
884 | |||
885 | # we don't know yet if it's a brain or an object |
||
886 | path = hasattr(obj, 'getPath') and obj.getPath() or \ |
||
887 | "/".join(obj.getPhysicalPath()) |
||
888 | |||
889 | # This item must be rendered, we need the object instead of a brain |
||
890 | obj = obj.getObject() if hasattr(obj, 'getObject') else obj |
||
891 | |||
892 | # check if the item must be rendered or not (prevents from |
||
893 | # doing it later in folderitems) and dealing with paging |
||
894 | if not obj or not self.isItemAllowed(obj): |
||
895 | continue |
||
896 | |||
897 | uid = obj.UID() |
||
898 | title = obj.Title() |
||
899 | description = obj.Description() |
||
900 | icon = plone_layout.getIcon(obj) |
||
901 | url = obj.absolute_url() |
||
902 | relative_url = obj.absolute_url(relative=True) |
||
903 | |||
904 | fti = portal_types.get(obj.portal_type) |
||
905 | if fti is not None: |
||
906 | type_title_msgid = fti.Title() |
||
907 | else: |
||
908 | type_title_msgid = obj.portal_type |
||
909 | |||
910 | url_href_title = '%s at %s: %s' % ( |
||
911 | t(type_title_msgid), |
||
912 | path, |
||
913 | to_utf8(description)) |
||
914 | |||
915 | modified = self.ulocalized_time(obj.modified()), |
||
916 | |||
917 | # element css classes |
||
918 | type_class = 'contenttype-' + \ |
||
919 | plone_utils.normalizeString(obj.portal_type) |
||
920 | |||
921 | state_class = '' |
||
922 | states = {} |
||
923 | for w in self.workflow.getWorkflowsFor(obj): |
||
924 | state = w._getWorkflowStateOf(obj).id |
||
925 | states[w.state_var] = state |
||
926 | state_class += "state-%s " % state |
||
927 | |||
928 | results_dict = dict( |
||
929 | obj=obj, |
||
930 | id=obj.getId(), |
||
931 | title=title, |
||
932 | uid=uid, |
||
933 | path=path, |
||
934 | url=url, |
||
935 | fti=fti, |
||
936 | item_data=json.dumps([]), |
||
937 | url_href_title=url_href_title, |
||
938 | obj_type=obj.Type, |
||
939 | size=obj.getObjSize, |
||
940 | modified=modified, |
||
941 | icon=icon.html_tag(), |
||
942 | type_class=type_class, |
||
943 | # a list of lookups for single-value-select fields |
||
944 | choices={}, |
||
945 | state_class=state_class, |
||
946 | relative_url=relative_url, |
||
947 | view_url=url, |
||
948 | table_row_class="", |
||
949 | category='None', |
||
950 | |||
951 | # a list of names of fields that may be edited on this item |
||
952 | allow_edit=[], |
||
953 | |||
954 | # a list of names of fields that are compulsory (if editable) |
||
955 | required=[], |
||
956 | # a dict where the column name works as a key and the value is |
||
957 | # the name of the field related with the column. It is used |
||
958 | # when the name given to the column and the content field it |
||
959 | # represents diverges. bika_listing_table_items.pt defines an |
||
960 | # attribute for each item, this attribute is named 'field' and |
||
961 | # the system fills it taking advantage of this dictionary or |
||
962 | # the name of the column if it isn't defined in the dict. |
||
963 | field={}, |
||
964 | # "before", "after" and replace: dictionary (key is column ID) |
||
965 | # A snippet of HTML which will be rendered |
||
966 | # before/after/instead of the table cell content. |
||
967 | before={}, # { before : "<a href=..>" } |
||
968 | after={}, |
||
969 | replace={}, |
||
970 | ) |
||
971 | |||
972 | rs = None |
||
973 | wf_state_var = None |
||
974 | |||
975 | workflows = self.workflow.getWorkflowsFor(obj) |
||
976 | for wf in workflows: |
||
977 | if wf.state_var: |
||
978 | wf_state_var = wf.state_var |
||
979 | break |
||
980 | |||
981 | if wf_state_var is not None: |
||
982 | rs = self.workflow.getInfoFor(obj, wf_state_var) |
||
983 | st_title = self.workflow.getTitleForStateOnType( |
||
984 | rs, obj.portal_type) |
||
985 | st_title = t(_(st_title)) |
||
986 | |||
987 | if rs: |
||
988 | results_dict['review_state'] = rs |
||
989 | |||
990 | for state_var, state in states.items(): |
||
991 | if not st_title: |
||
992 | st_title = self.workflow.getTitleForStateOnType( |
||
993 | state, obj.portal_type) |
||
994 | results_dict[state_var] = state |
||
995 | results_dict['state_title'] = st_title |
||
996 | |||
997 | results_dict['class'] = {} |
||
998 | |||
999 | # As far as I am concerned, adapters for IFieldIcons are only used |
||
1000 | # for Analysis content types. Since AnalysesView is not using this |
||
1001 | # "classic" folderitems from bikalisting anymore, this logic has |
||
1002 | # been added in AnalysesView. Even though, this logic hasn't been |
||
1003 | # removed from here, cause this _folderitems function is marked as |
||
1004 | # deprecated, so it will be eventually removed alltogether. |
||
1005 | for name, adapter in getAdapters((obj,), IFieldIcons): |
||
1006 | auid = obj.UID() if hasattr(obj, 'UID') and callable( |
||
1007 | obj.UID) else None |
||
1008 | if not auid: |
||
1009 | continue |
||
1010 | alerts = adapter() |
||
1011 | # logger.info(str(alerts)) |
||
1012 | if alerts and auid in alerts: |
||
1013 | if auid in self.field_icons: |
||
1014 | self.field_icons[auid].extend(alerts[auid]) |
||
1015 | else: |
||
1016 | self.field_icons[auid] = alerts[auid] |
||
1017 | |||
1018 | # Search for values for all columns in obj |
||
1019 | for key in self.columns.keys(): |
||
1020 | # if the key is already in the results dict |
||
1021 | # then we don't replace it's value |
||
1022 | value = results_dict.get(key, '') |
||
1023 | View Code Duplication | if key not in results_dict: |
|
1024 | attrobj = getFromString(obj, key) |
||
1025 | value = attrobj if attrobj else value |
||
1026 | |||
1027 | # Custom attribute? Inspect to set the value |
||
1028 | # for the current column dinamically |
||
1029 | vattr = self.columns[key].get('attr', None) |
||
1030 | if vattr: |
||
1031 | attrobj = getFromString(obj, vattr) |
||
1032 | value = attrobj if attrobj else value |
||
1033 | results_dict[key] = value |
||
1034 | |||
1035 | # Replace with an url? |
||
1036 | replace_url = self.columns[key].get('replace_url', None) |
||
1037 | if replace_url: |
||
1038 | attrobj = getFromString(obj, replace_url) |
||
1039 | if attrobj: |
||
1040 | results_dict['replace'][key] = \ |
||
1041 | '<a href="%s">%s</a>' % (attrobj, value) |
||
1042 | |||
1043 | # The item basics filled. Delegate additional actions to folderitem |
||
1044 | # service. folderitem service is frequently overriden by child |
||
1045 | # objects |
||
1046 | item = self.folderitem(obj, results_dict, idx) |
||
1047 | if item: |
||
1048 | results.append(item) |
||
1049 | idx += 1 |
||
1050 | |||
1051 | return results |
||
1052 |