Refactoring: Moved check parameters from unsorted.py to dedicated modules (CMK-1393)
[check_mk.git] / cmk / gui / table.py
blob5d964fd41d5437663f88e235b2ff21baa6516284
1 #!/usr/bin/python
2 # -*- encoding: utf-8; py-indent-offset: 4 -*-
3 # +------------------------------------------------------------------+
4 # | ____ _ _ __ __ _ __ |
5 # | / ___| |__ ___ ___| | __ | \/ | |/ / |
6 # | | | | '_ \ / _ \/ __| |/ / | |\/| | ' / |
7 # | | |___| | | | __/ (__| < | | | | . \ |
8 # | \____|_| |_|\___|\___|_|\_\___|_| |_|_|\_\ |
9 # | |
10 # | Copyright Mathias Kettner 2014 mk@mathias-kettner.de |
11 # +------------------------------------------------------------------+
13 # This file is part of Check_MK.
14 # The official homepage is at http://mathias-kettner.de/check_mk.
16 # check_mk is free software; you can redistribute it and/or modify it
17 # under the terms of the GNU General Public License as published by
18 # the Free Software Foundation in version 2. check_mk is distributed
19 # in the hope that it will be useful, but WITHOUT ANY WARRANTY; with-
20 # out even the implied warranty of MERCHANTABILITY or FITNESS FOR A
21 # PARTICULAR PURPOSE. See the GNU General Public License for more de-
22 # tails. You should have received a copy of the GNU General Public
23 # License along with GNU Make; see the file COPYING. If not, write
24 # to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
25 # Boston, MA 02110-1301 USA.
27 from contextlib import contextmanager
28 import re
29 import json
31 import cmk.gui.utils as utils
32 import cmk.gui.config as config
33 from cmk.gui.i18n import _
34 from cmk.gui.globals import html
35 from cmk.gui.htmllib import HTML
38 @contextmanager
39 def table_element(table_id=None, title=None, **kwargs):
40 with html.plugged():
41 table = Table(table_id, title, **kwargs)
42 try:
43 yield table
44 finally:
45 table._finish_previous()
46 table._end()
50 # .--Table---------------------------------------------------------------.
51 # | _____ _ _ |
52 # | |_ _|_ _| |__ | | ___ |
53 # | | |/ _` | '_ \| |/ _ \ |
54 # | | | (_| | |_) | | __/ |
55 # | |_|\__,_|_.__/|_|\___| |
56 # | |
57 # +----------------------------------------------------------------------+
58 # | |
59 # | Usage: |
60 # | |
61 # | with table_element() as table: |
62 # | table.row() |
63 # | table.cell("header", "content") |
64 # | |
65 # '----------------------------------------------------------------------'
68 class Table(object):
69 def __init__(self, table_id=None, title=None, **kwargs):
70 super(Table, self).__init__()
71 self.next_func = lambda: None
72 self.next_header = None
74 # Use our pagename as table id if none is specified
75 table_id = table_id if table_id is not None else html.myfile
77 # determine row limit
78 try:
79 limit = config.table_row_limit
80 except:
81 limit = None
82 limit = kwargs.get('limit', limit)
83 if html.request.var('limit') == 'none' or kwargs.get("output_format", "html") != "html":
84 limit = None
86 self.id = table_id
87 self.title = title
88 self.rows = []
89 self.limit = limit
90 self.headers = []
91 self.options = {
92 "collect_headers": False, # also: True, "finished"
93 "omit_if_empty": kwargs.get("omit_if_empty", False),
94 "omit_headers": kwargs.get("omit_headers", False),
95 "searchable": kwargs.get("searchable", True),
96 "sortable": kwargs.get("sortable", True),
97 "foldable": kwargs.get("foldable", False),
98 "output_format": kwargs.get("output_format", "html"), # possible: html, csv, fetch
101 self.empty_text = kwargs.get("empty_text", _("No entries."))
102 self.help = kwargs.get("help", None)
103 self.css = kwargs.get("css", None)
104 self.mode = 'row'
106 def row(self, *posargs, **kwargs):
107 self._finish_previous()
108 self.next_func = lambda: self._add_row(*posargs, **kwargs)
110 def text_cell(self, *args, **kwargs):
111 self.cell(*args, escape_text=True, **kwargs)
113 def cell(self, *posargs, **kwargs):
114 self._finish_previous()
115 self.next_func = lambda: self._add_cell(*posargs, **kwargs)
117 def _finish_previous(self):
118 self.next_func()
119 self.next_func = lambda: None
121 def _add_row(self, css=None, state=0, collect_headers=True, fixed=False, **attrs):
122 if self.next_header:
123 self.rows.append((self.next_header, None, "header", True, attrs))
124 self.next_header = None
125 self.rows.append(([], css, state, fixed, attrs))
126 if collect_headers:
127 if self.options["collect_headers"] is False:
128 self.options["collect_headers"] = True
129 elif self.options["collect_headers"] is True:
130 self.options["collect_headers"] = "finished"
131 elif not collect_headers and self.options["collect_headers"] is True:
132 self.options["collect_headers"] = False
134 def _add_cell(self,
135 title="",
136 text="",
137 css=None,
138 help_txt=None,
139 colspan=None,
140 sortable=True,
141 escape_text=False):
142 if escape_text:
143 text = html.permissive_attrencode(text)
144 else:
145 if isinstance(text, HTML):
146 text = "%s" % text
147 if not isinstance(text, unicode):
148 text = str(text)
150 htmlcode = text + html.drain()
152 if self.options["collect_headers"] is True:
153 # small helper to make sorting introducion easier. Cells which contain
154 # buttons are never sortable
155 if css and 'buttons' in css and sortable:
156 sortable = False
157 self.headers.append((title, css, help_txt, sortable))
159 self.rows[-1][0].append((htmlcode, css, colspan))
161 # Intermediate title, shown as soon as there is a following row.
162 # We store the group headers in the list of rows, with css None
163 # and state set to "header"
164 def groupheader(self, title):
165 self.next_header = title
167 def _end(self):
168 if not self.rows and self.options["omit_if_empty"]:
169 return
171 if self.options["output_format"] == "csv":
172 self._write_csv(csv_separator=html.request.var("csv_separator", ";"))
173 return
175 if self.title:
176 if self.options["foldable"]:
177 html.begin_foldable_container(
178 treename="table",
179 id_=self.id,
180 isopen=True,
181 indent=False,
182 title=html.render_h3(self.title, class_=["treeangle", "title"]))
183 else:
184 html.open_h3()
185 html.write(self.title)
186 html.close_h3()
188 if self.help:
189 html.help(self.help)
191 if not self.rows:
192 html.div(self.empty_text, class_="info")
193 return
195 # Controls whether or not actions are available for a table
196 rows, actions_enabled, actions_visible, search_term, user_opts = self._evaluate_user_opts()
198 # Apply limit after search / sorting etc.
199 num_rows_unlimited = len(rows)
200 limit = self.limit
201 if limit is not None:
202 # only use rows up to the limit plus the fixed rows
203 rows = [rows[i] for i in range(num_rows_unlimited) if i < limit or rows[i][3]]
204 # Display corrected number of rows
205 num_rows_unlimited -= len([r for r in rows if r[3]])
207 # Render header
208 self._write_table(rows, actions_enabled, actions_visible, search_term)
210 if self.title and self.options["foldable"]:
211 html.end_foldable_container()
213 if limit is not None and num_rows_unlimited > limit:
214 html.message(
215 _('This table is limited to show only %d of %d rows. '
216 'Click <a href="%s">here</a> to disable the limitation.') %
217 (limit, num_rows_unlimited, html.makeuri([('limit', 'none')])))
219 if actions_enabled:
220 config.user.save_file("tableoptions", user_opts)
221 return
223 def _evaluate_user_opts(self):
225 table_id = self.id
226 rows = self.rows
228 search_term = None
229 actions_enabled = (self.options["searchable"] or self.options["sortable"])
231 if not actions_enabled:
232 return rows, False, False, None, None
233 else:
234 user_opts = config.user.load_file("tableoptions", {})
235 user_opts.setdefault(table_id, {})
236 table_opts = user_opts[table_id]
238 # Handle the initial visibility of the actions
239 actions_visible = user_opts[table_id].get('actions_visible', False)
240 if html.request.var('_%s_actions' % table_id):
241 actions_visible = html.request.var('_%s_actions' % table_id) == '1'
242 user_opts[table_id]['actions_visible'] = actions_visible
244 if html.request.var('_%s_reset' % table_id):
245 html.request.del_var('_%s_search' % table_id)
246 if 'search' in table_opts:
247 del table_opts['search'] # persist
249 if self.options["searchable"]:
250 # Search is always lower case -> case insensitive
251 search_term = html.get_unicode_input('_%s_search' % table_id,
252 table_opts.get('search', '')).lower()
253 if search_term:
254 html.request.set_var('_%s_search' % table_id, search_term)
255 table_opts['search'] = search_term # persist
256 rows = _filter_rows(rows, search_term)
258 if html.request.var('_%s_reset_sorting' % table_id):
259 html.request.del_var('_%s_sort' % table_id)
260 if 'sort' in table_opts:
261 del table_opts['sort'] # persist
263 if self.options["sortable"]:
264 # Now apply eventual sorting settings
265 sort = html.request.var('_%s_sort' % table_id, table_opts.get('sort'))
266 if sort is not None:
267 html.request.set_var('_%s_sort' % table_id, sort)
268 table_opts['sort'] = sort # persist
269 sort_col, sort_reverse = map(int, sort.split(',', 1))
270 rows = _sort_rows(rows, sort_col, sort_reverse)
272 return rows, actions_enabled, actions_visible, search_term, user_opts
274 def _write_table(self, rows, actions_enabled, actions_visible, search_term):
275 headinfo = _("1 row") if len(rows) == 1 else _("%d rows") % len(rows)
276 html.javascript("cmk.utils.update_header_info(%s);" % json.dumps(headinfo))
278 table_id = self.id
279 num_cols = len(self.headers)
281 html.open_table(class_=["data", "oddeven", self.css])
283 # If we have no group headers then paint the headers now
284 if self.rows and self.rows[0][2] != "header":
285 self._render_headers(actions_enabled, actions_visible)
287 if actions_enabled and actions_visible:
288 html.open_tr(class_=["data", "even0", "actions"])
289 html.open_td(colspan=num_cols)
290 if not html.in_form():
291 html.begin_form("%s_actions" % table_id)
293 if self.options["searchable"]:
294 html.open_div(class_="search")
295 html.text_input("_%s_search" % table_id)
296 html.button("_%s_submit" % table_id, _("Search"))
297 html.button("_%s_reset" % table_id, _("Reset search"))
298 html.set_focus("_%s_search" % table_id)
299 html.close_div()
301 if html.request.has_var('_%s_sort' % table_id):
302 html.open_div(class_=["sort"])
303 html.button("_%s_reset_sorting" % table_id, _("Reset sorting"))
304 html.close_div()
306 if not html.in_form():
307 html.begin_form("%s_actions" % table_id)
309 html.hidden_fields()
310 html.end_form()
311 html.close_tr()
313 for nr, (row_spec, css, state, _fixed, attrs) in enumerate(rows):
315 if not css and "class_" in attrs:
316 css = attrs.pop("class_")
317 if not css and "class" in attrs:
318 css = attrs.pop("class")
320 # Intermediate header
321 if state == "header":
322 # Show the header only, if at least one (non-header) row follows
323 if nr < len(rows) - 1 and rows[nr + 1][2] != "header":
324 html.open_tr(class_="groupheader")
325 html.open_td(colspan=num_cols)
326 html.open_h3()
327 html.write(row_spec)
328 html.close_h3()
329 html.close_td()
330 html.close_tr()
332 self._render_headers(actions_enabled, actions_visible)
333 continue
335 oddeven_name = "even" if (nr - 1) % 2 == 0 else "odd"
337 html.open_tr(
338 class_=["data", "%s%d" % (oddeven_name, state), css if css else None], **attrs)
339 for cell_content, css_classes, colspan in row_spec:
340 html.open_td(
341 class_=css_classes if css_classes else None,
342 colspan=colspan if colspan else None)
343 html.write(cell_content)
344 html.close_td()
345 html.close_tr()
347 if not rows and search_term:
348 html.open_tr(class_=["data", "odd0", "no_match"])
349 html.td(_('Found no matching rows. Please try another search term.'), colspan=num_cols)
350 html.close_tr()
352 html.close_table()
354 def _write_csv(self, csv_separator):
356 rows = self.rows
357 headers = self.headers
358 limit = self.limit
359 omit_headers = self.options["omit_headers"]
361 # Apply limit after search / sorting etc.
362 if limit is not None:
363 rows = rows[:limit]
365 # If we have no group headers then paint the headers now
366 if not omit_headers and self.rows and self.rows[0][2] != "header":
367 html.write(
368 csv_separator.join(
369 [html.strip_tags(header) or ""
370 for (header, _css, _help, _sortable) in headers]) + "\n")
372 for row_spec, _css, _state, _fixed, _attrs in rows:
373 html.write(
374 csv_separator.join([
375 html.strip_tags(cell_content)
376 for cell_content, _css_classes, _colspan in row_spec
378 html.write("\n")
380 def _render_headers(self, actions_enabled, actions_visible):
381 if self.options["omit_headers"]:
382 return
384 table_id = self.id
386 html.open_tr()
387 first_col = True
388 for nr, (header, css, help_txt, sortable) in enumerate(self.headers):
389 text = header
391 if help_txt:
392 header = '<span title="%s">%s</span>' % (html.attrencode(help_txt), header)
394 css_class = "header_%s" % css if css else None
396 if not self.options["sortable"] or not sortable:
397 html.open_th(class_=css_class)
398 else:
399 reverse = 0
400 sort = html.request.var('_%s_sort' % table_id)
401 if sort:
402 sort_col, sort_reverse = map(int, sort.split(',', 1))
403 if sort_col == nr:
404 reverse = 1 if sort_reverse == 0 else 0
406 action_uri = html.makeactionuri([('_%s_sort' % table_id, '%d,%d' % (nr, reverse))])
407 html.open_th(
408 class_=["sort", css_class],
409 title=_("Sort by %s") % text,
410 onclick="location.href='%s'" % action_uri)
412 # Add the table action link
413 if first_col:
414 first_col = False
415 if actions_enabled:
416 if not header:
417 header = "&nbsp;" # Fixes layout problem with white triangle
418 if actions_visible:
419 state = '0'
420 help_txt = _('Hide table actions')
421 img = 'table_actions_on'
422 else:
423 state = '1'
424 help_txt = _('Display table actions')
425 img = 'table_actions_off'
426 html.open_div(class_=["toggle_actions"])
427 html.icon_button(
428 html.makeuri([('_%s_actions' % table_id, state)]),
429 help_txt,
430 img,
431 cssclass='toggle_actions')
432 html.open_span()
433 html.write(header)
434 html.close_span()
435 html.close_div()
436 else:
437 html.write(header)
438 else:
439 html.write(header)
441 html.close_th()
442 html.close_tr()
445 def _filter_rows(rows, search_term):
446 filtered_rows = []
447 match_regex = re.compile(search_term, re.IGNORECASE)
449 for row_spec, css, state, fixed, attrs in rows:
450 if state == "header" or fixed:
451 filtered_rows.append((row_spec, css, state, fixed, attrs))
452 continue # skip filtering of headers or fixed rows
454 for cell_content, _css_classes, _colspan in row_spec:
455 if match_regex.search(cell_content):
456 filtered_rows.append((row_spec, css, state, fixed, attrs))
457 break # skip other cells when matched
458 return filtered_rows
461 def _sort_rows(rows, sort_col, sort_reverse):
463 # remove and remind fixed rows, add to separate list
464 fixed_rows = []
465 for index, row_spec in enumerate(rows[:]):
466 if row_spec[3] is True:
467 rows.remove(row_spec)
468 fixed_rows.append((index, row_spec))
470 # Then use natural sorting to sort the list. Note: due to a
471 # change in the number of columns of a table in different software
472 # versions the cmp-function might fail. This is because the sorting
473 # column is persisted in a user file. So we ignore exceptions during
474 # sorting. This gives the user the chance to change the sorting and
475 # see the table in the first place.
476 try:
477 rows.sort(
478 cmp=lambda a, b: utils.cmp_num_split(a[0][sort_col][0], b[0][sort_col][0]),
479 reverse=sort_reverse == 1)
480 except IndexError:
481 pass
483 # Now re-add the removed "fixed" rows to the list again
484 if fixed_rows:
485 for index, row_spec in fixed_rows:
486 rows.insert(index, row_spec)
488 return rows