1# Copyright 2012 Nebula, Inc. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may 4# not use this file except in compliance with the License. You may obtain 5# a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations 13# under the License. 14 15import collections 16import collections.abc 17import copy 18import inspect 19import json 20import logging 21from operator import attrgetter 22import sys 23 24from django.conf import settings 25from django.core import exceptions as core_exceptions 26from django import forms 27from django.http import HttpResponse 28from django import template 29from django.template.defaultfilters import slugify 30from django.template.defaultfilters import truncatechars 31from django.template.loader import render_to_string 32from django import urls 33from django.utils import encoding 34from django.utils.html import escape 35from django.utils import http 36from django.utils.http import urlencode 37from django.utils.safestring import mark_safe 38from django.utils import termcolors 39from django.utils.translation import ugettext_lazy as _ 40 41from horizon import conf 42from horizon import exceptions 43from horizon.forms import ThemableCheckboxInput 44from horizon import messages 45from horizon.tables.actions import BatchAction 46from horizon.tables.actions import FilterAction 47from horizon.tables.actions import LinkAction 48from horizon.utils import html 49from horizon.utils import settings as utils_settings 50 51 52LOG = logging.getLogger(__name__) 53PALETTE = termcolors.PALETTES[termcolors.DEFAULT_PALETTE] 54STRING_SEPARATOR = "__" 55 56 57class Column(html.HTMLElement): 58 """A class which represents a single column in a :class:`.DataTable`. 59 60 .. attribute:: transform 61 62 A string or callable. If ``transform`` is a string, it should be the 63 name of the attribute on the underlying data class which 64 should be displayed in this column. If it is a callable, it 65 will be passed the current row's data at render-time and should 66 return the contents of the cell. Required. 67 68 .. attribute:: verbose_name 69 70 The name for this column which should be used for display purposes. 71 Defaults to the value of ``transform`` with the first letter 72 of each word capitalized if the ``transform`` is not callable, 73 otherwise it defaults to an empty string (``""``). 74 75 .. attribute:: sortable 76 77 Boolean to determine whether this column should be sortable or not. 78 Defaults to ``True``. 79 80 .. attribute:: hidden 81 82 Boolean to determine whether or not this column should be displayed 83 when rendering the table. Default: ``False``. 84 85 .. attribute:: link 86 87 A string or callable which returns a URL which will be wrapped around 88 this column's text as a link. 89 90 .. attribute:: allowed_data_types 91 92 A list of data types for which the link should be created. 93 Default is an empty list (``[]``). 94 95 When the list is empty and the ``link`` attribute is not None, all the 96 rows under this column will be links. 97 98 .. attribute:: status 99 100 Boolean designating whether or not this column represents a status 101 (i.e. "enabled/disabled", "up/down", "active/inactive"). 102 Default: ``False``. 103 104 .. attribute:: status_choices 105 106 A tuple of tuples representing the possible data values for the 107 status column and their associated boolean equivalent. Positive 108 states should equate to ``True``, negative states should equate 109 to ``False``, and indeterminate states should be ``None``. 110 111 Values are compared in a case-insensitive manner. 112 113 Example (these are also the default values):: 114 115 status_choices = ( 116 ('enabled', True), 117 ('true', True), 118 ('up', True), 119 ('active', True), 120 ('yes', True), 121 ('on', True), 122 ('none', None), 123 ('unknown', None), 124 ('', None), 125 ('disabled', False), 126 ('down', False), 127 ('false', False), 128 ('inactive', False), 129 ('no', False), 130 ('off', False), 131 ) 132 133 .. attribute:: display_choices 134 135 A tuple of tuples representing the possible values to substitute 136 the data when displayed in the column cell. 137 138 .. attribute:: empty_value 139 140 A string or callable to be used for cells which have no data. 141 Defaults to the string ``"-"``. 142 143 .. attribute:: summation 144 145 A string containing the name of a summation method to be used in 146 the generation of a summary row for this column. By default the 147 options are ``"sum"`` or ``"average"``, which behave as expected. 148 Optional. 149 150 .. attribute:: filters 151 152 A list of functions (often template filters) to be applied to the 153 value of the data for this column prior to output. This is effectively 154 a shortcut for writing a custom ``transform`` function in simple cases. 155 156 .. attribute:: classes 157 158 An iterable of CSS classes which should be added to this column. 159 Example: ``classes=('foo', 'bar')``. 160 161 .. attribute:: attrs 162 163 A dict of HTML attribute strings which should be added to this column. 164 Example: ``attrs={"data-foo": "bar"}``. 165 166 .. attribute:: cell_attributes_getter 167 168 A callable to get the HTML attributes of a column cell depending 169 on the data. For example, to add additional description or help 170 information for data in a column cell (e.g. in Images panel, for the 171 column 'format'):: 172 173 helpText = { 174 'ARI':'Amazon Ramdisk Image', 175 'QCOW2':'QEMU' Emulator' 176 } 177 178 getHoverHelp(data): 179 text = helpText.get(data, None) 180 if text: 181 return {'title': text} 182 else: 183 return {} 184 ... 185 ... 186 cell_attributes_getter = getHoverHelp 187 188 .. attribute:: truncate 189 190 An integer for the maximum length of the string in this column. If the 191 length of the data in this column is larger than the supplied number, 192 the data for this column will be truncated and an ellipsis will be 193 appended to the truncated data. 194 Defaults to ``None``. 195 196 .. attribute:: link_classes 197 198 An iterable of CSS classes which will be added when the column's text 199 is displayed as a link. 200 This is left for backward compatibility. Deprecated in favor of the 201 link_attributes attribute. 202 Example: ``link_classes=('link-foo', 'link-bar')``. 203 Defaults to ``None``. 204 205 .. attribute:: wrap_list 206 207 Boolean value indicating whether the contents of this cell should be 208 wrapped in a ``<ul></ul>`` tag. Useful in conjunction with Django's 209 ``unordered_list`` template filter. Defaults to ``False``. 210 211 .. attribute:: form_field 212 213 A form field used for inline editing of the column. A django 214 forms.Field can be used or django form.Widget can be used. 215 216 Example: ``form_field=forms.CharField()``. 217 Defaults to ``None``. 218 219 .. attribute:: form_field_attributes 220 221 The additional html attributes that will be rendered to form_field. 222 Example: ``form_field_attributes={'class': 'bold_input_field'}``. 223 Defaults to ``None``. 224 225 .. attribute:: update_action 226 227 The class that inherits from tables.actions.UpdateAction, update_cell 228 method takes care of saving inline edited data. The tables.base.Row 229 get_data method needs to be connected to table for obtaining the data. 230 Example: ``update_action=UpdateCell``. 231 Defaults to ``None``. 232 233 .. attribute:: link_attrs 234 235 A dict of HTML attribute strings which should be added when the 236 column's text is displayed as a link. 237 Examples: 238 ``link_attrs={"data-foo": "bar"}``. 239 ``link_attrs={"target": "_blank", "class": "link-foo link-bar"}``. 240 Defaults to ``None``. 241 242 .. attribute:: policy_rules 243 244 List of scope and rule tuples to do policy checks on, the 245 composition of which is (scope, rule) 246 247 * scope: service type managing the policy for action 248 * rule: string representing the action to be checked 249 250 for a policy that requires a single rule check, 251 policy_rules should look like: 252 253 .. code-block:: none 254 255 "(("compute", "compute:create_instance"),)" 256 257 for a policy that requires multiple rule checks, 258 rules should look like: 259 260 .. code-block:: none 261 262 "(("identity", "identity:list_users"), 263 ("identity", "identity:list_roles"))" 264 265 .. attribute:: help_text 266 267 A string of simple help text displayed in a tooltip when you hover 268 over the help icon beside the Column name. Defaults to ``None``. 269 """ 270 summation_methods = { 271 "sum": sum, 272 "average": lambda data: sum(data, 0.0) / len(data) 273 } 274 # Used to retain order when instantiating columns on a table 275 creation_counter = 0 276 277 transform = None 278 name = None 279 verbose_name = None 280 status_choices = ( 281 ('enabled', True), 282 ('true', True), 283 ('up', True), 284 ('yes', True), 285 ('active', True), 286 ('on', True), 287 ('none', None), 288 ('unknown', None), 289 ('', None), 290 ('disabled', False), 291 ('down', False), 292 ('false', False), 293 ('inactive', False), 294 ('no', False), 295 ('off', False), 296 ) 297 298 def __init__(self, transform, verbose_name=None, sortable=True, 299 link=None, allowed_data_types=None, hidden=False, attrs=None, 300 status=False, status_choices=None, display_choices=None, 301 empty_value=None, filters=None, classes=None, summation=None, 302 auto=None, truncate=None, link_classes=None, wrap_list=False, 303 form_field=None, form_field_attributes=None, 304 update_action=None, link_attrs=None, policy_rules=None, 305 cell_attributes_getter=None, help_text=None): 306 307 allowed_data_types = allowed_data_types or [] 308 self.classes = list(classes or getattr(self, "classes", [])) 309 super().__init__() 310 self.attrs.update(attrs or {}) 311 312 if callable(transform): 313 self.transform = transform 314 self.name = "<%s callable>" % transform.__name__ 315 else: 316 self.transform = str(transform) 317 self.name = self.transform 318 319 # Empty string is a valid value for verbose_name 320 if verbose_name is None: 321 if callable(transform): 322 self.verbose_name = '' 323 else: 324 self.verbose_name = self.transform.title() 325 else: 326 self.verbose_name = str(verbose_name) 327 328 self.auto = auto 329 self.sortable = sortable 330 self.link = link 331 self.allowed_data_types = allowed_data_types 332 self.hidden = hidden 333 self.status = status 334 self.empty_value = empty_value or _('-') 335 self.filters = filters or [] 336 self.truncate = truncate 337 self.wrap_list = wrap_list 338 self.form_field = form_field 339 self.form_field_attributes = form_field_attributes or {} 340 self.update_action = update_action 341 self.link_attrs = link_attrs or {} 342 self.policy_rules = policy_rules or [] 343 self.help_text = help_text 344 if link_classes: 345 self.link_attrs['class'] = ' '.join(link_classes) 346 self.cell_attributes_getter = cell_attributes_getter 347 348 if status_choices: 349 self.status_choices = status_choices 350 self.display_choices = display_choices 351 352 if summation is not None and summation not in self.summation_methods: 353 raise ValueError( 354 "Summation method %(summation)s must be one of %(keys)s." % 355 {'summation': summation, 356 'keys': ", ".join(self.summation_methods.keys())}) 357 self.summation = summation 358 359 self.creation_counter = Column.creation_counter 360 Column.creation_counter += 1 361 362 if self.sortable and not self.auto: 363 self.classes.append("sortable") 364 if self.hidden: 365 self.classes.append("hide") 366 if self.link is not None: 367 self.classes.append('anchor') 368 369 def __str__(self): 370 return self.verbose_name 371 372 def __repr__(self): 373 return '<%s: %s>' % (self.__class__.__name__, self.name) 374 375 def allowed(self, request): 376 """Determine whether processing/displaying the column is allowed. 377 378 It is determined based on the current request. 379 """ 380 if not self.policy_rules: 381 return True 382 383 policy_check = utils_settings.import_setting("POLICY_CHECK_FUNCTION") 384 385 if policy_check: 386 return policy_check(self.policy_rules, request) 387 return True 388 389 def get_raw_data(self, datum): 390 """Returns the raw data for this column. 391 392 No filters or formatting are applied to the returned data. 393 This is useful when doing calculations on data in the table. 394 """ 395 # Callable transformations 396 if callable(self.transform): 397 data = self.transform(datum) 398 # Dict lookups 399 elif (isinstance(datum, collections.abc.Mapping) and 400 self.transform in datum): 401 data = datum.get(self.transform) 402 else: 403 # Basic object lookups 404 data = getattr(datum, self.transform, None) 405 if not hasattr(datum, self.transform): 406 msg = "The attribute %(attr)s doesn't exist on %(obj)s." 407 LOG.debug(termcolors.colorize(msg, **PALETTE['ERROR']), 408 {'attr': self.transform, 'obj': datum}) 409 return data 410 411 def get_data(self, datum): 412 """Returns the final display data for this column from the given inputs. 413 414 The return value will be either the attribute specified for this column 415 or the return value of the attr:`~horizon.tables.Column.transform` 416 method for this column. 417 """ 418 datum_id = self.table.get_object_id(datum) 419 420 if datum_id in self.table._data_cache[self]: 421 return self.table._data_cache[self][datum_id] 422 423 data = self.get_raw_data(datum) 424 display_value = None 425 426 if self.display_choices: 427 display_value = [display for (value, display) in 428 self.display_choices 429 if value.lower() == (data or '').lower()] 430 431 if display_value: 432 data = display_value[0] 433 else: 434 for filter_func in self.filters: 435 try: 436 data = filter_func(data) 437 except Exception: 438 msg = ("Filter '%(filter)s' failed with data " 439 "'%(data)s' on column '%(col_name)s'") 440 args = {'filter': filter_func.__name__, 441 'data': data, 442 'col_name': self.verbose_name} 443 LOG.warning(msg, args) 444 445 if data and self.truncate: 446 data = truncatechars(data, self.truncate) 447 448 self.table._data_cache[self][datum_id] = data 449 450 return self.table._data_cache[self][datum_id] 451 452 def get_link_url(self, datum): 453 """Returns the final value for the column's ``link`` property. 454 455 If ``allowed_data_types`` of this column is not empty and the datum 456 has an assigned type, check if the datum's type is in the 457 ``allowed_data_types`` list. If not, the datum won't be displayed 458 as a link. 459 460 If ``link`` is a callable, it will be passed the current data object 461 and should return a URL. Otherwise ``get_link_url`` will attempt to 462 call ``reverse`` on ``link`` with the object's id as a parameter. 463 Failing that, it will simply return the value of ``link``. 464 """ 465 if self.allowed_data_types: 466 data_type_name = self.table._meta.data_type_name 467 data_type = getattr(datum, data_type_name, None) 468 if data_type and (data_type not in self.allowed_data_types): 469 return None 470 obj_id = self.table.get_object_id(datum) 471 if callable(self.link): 472 if 'request' in inspect.getfullargspec(self.link).args: 473 return self.link(datum, request=self.table.request) 474 return self.link(datum) 475 try: 476 return urls.reverse(self.link, args=(obj_id,)) 477 except urls.NoReverseMatch: 478 return self.link 479 480 if settings.INTEGRATION_TESTS_SUPPORT: 481 def get_default_attrs(self): 482 attrs = super().get_default_attrs() 483 attrs.update({'data-selenium': self.name}) 484 return attrs 485 486 def get_summation(self): 487 """Returns the summary value for the data in this column. 488 489 It returns the summary value if a valid summation method is 490 specified for it. Otherwise returns ``None``. 491 """ 492 if self.summation not in self.summation_methods: 493 return None 494 495 summation_function = self.summation_methods[self.summation] 496 data = [self.get_raw_data(datum) for datum in self.table.data] 497 data = [raw_data for raw_data in data if raw_data is not None] 498 499 if data: 500 try: 501 summation = summation_function(data) 502 for filter_func in self.filters: 503 summation = filter_func(summation) 504 return summation 505 except TypeError: 506 pass 507 return None 508 509 510class WrappingColumn(Column): 511 """A column that wraps its contents. Useful for data like UUIDs or names""" 512 513 def __init__(self, *args, **kwargs): 514 super().__init__(*args, **kwargs) 515 self.classes.append('word-break') 516 517 518class Row(html.HTMLElement): 519 """Represents a row in the table. 520 521 When iterated, the ``Row`` instance will yield each of its cells. 522 523 Rows are capable of AJAX updating, with a little added work: 524 525 The ``ajax`` property needs to be set to ``True``, and 526 subclasses need to define a ``get_data`` method which returns a data 527 object appropriate for consumption by the table (effectively the "get" 528 lookup versus the table's "list" lookup). 529 530 The automatic update interval is configurable by setting the key 531 ``ajax_poll_interval`` in the ``HORIZON_CONFIG`` dictionary. 532 Default: ``2500`` (measured in milliseconds). 533 534 .. attribute:: table 535 536 The table which this row belongs to. 537 538 .. attribute:: datum 539 540 The data object which this row represents. 541 542 .. attribute:: id 543 544 A string uniquely representing this row composed of the table name 545 and the row data object's identifier. 546 547 .. attribute:: cells 548 549 The cells belonging to this row stored in a ``OrderedDict`` object. 550 This attribute is populated during instantiation. 551 552 .. attribute:: status 553 554 Boolean value representing the status of this row calculated from 555 the values of the table's ``status_columns`` if they are set. 556 557 .. attribute:: status_class 558 559 Returns a css class for the status of the row based on ``status``. 560 561 .. attribute:: ajax 562 563 Boolean value to determine whether ajax updating for this row is 564 enabled. 565 566 .. attribute:: ajax_action_name 567 568 String that is used for the query parameter key to request AJAX 569 updates. Generally you won't need to change this value. 570 Default: ``"row_update"``. 571 572 .. attribute:: ajax_cell_action_name 573 574 String that is used for the query parameter key to request AJAX 575 updates of cell. Generally you won't need to change this value. 576 It is also used for inline edit of the cell. 577 Default: ``"cell_update"``. 578 """ 579 ajax = False 580 ajax_action_name = "row_update" 581 ajax_cell_action_name = "cell_update" 582 583 def __init__(self, table, datum=None): 584 super().__init__() 585 self.table = table 586 self.datum = datum 587 self.selected = False 588 if self.datum: 589 self.load_cells() 590 else: 591 self.id = None 592 self.cells = [] 593 594 def load_cells(self, datum=None): 595 """Load the row's data and initialize all the cells in the row. 596 597 It also set the appropriate row properties which require 598 the row's data to be determined. 599 600 The row's data is provided either at initialization or as an 601 argument to this function. 602 603 This function is called automatically by 604 :meth:`~horizon.tables.Row.__init__` if the ``datum`` argument is 605 provided. However, by not providing the data during initialization 606 this function allows for the possibility of a two-step loading 607 pattern when you need a row instance but don't yet have the data 608 available. 609 """ 610 # Compile all the cells on instantiation. 611 table = self.table 612 if datum: 613 self.datum = datum 614 else: 615 datum = self.datum 616 cells = [] 617 for column in table.columns.values(): 618 cell = table._meta.cell_class(datum, column, self) 619 cells.append((column.name or column.auto, cell)) 620 self.cells = collections.OrderedDict(cells) 621 622 if self.ajax: 623 interval = conf.HORIZON_CONFIG['ajax_poll_interval'] 624 self.attrs['data-update-interval'] = interval 625 self.attrs['data-update-url'] = self.get_ajax_update_url() 626 self.classes.append("ajax-update") 627 628 self.attrs['data-object-id'] = table.get_object_id(datum) 629 630 # Add the row's status class and id to the attributes to be rendered. 631 self.classes.append(self.status_class) 632 id_vals = {"table": self.table.name, 633 "sep": STRING_SEPARATOR, 634 "id": table.get_object_id(datum)} 635 self.id = "%(table)s%(sep)srow%(sep)s%(id)s" % id_vals 636 self.attrs['id'] = self.id 637 638 # Add the row's display name if available 639 display_name = table.get_object_display(datum) 640 display_name_key = table.get_object_display_key(datum) 641 642 if display_name: 643 self.attrs['data-display'] = escape(display_name) 644 self.attrs['data-display-key'] = escape(display_name_key) 645 646 def __repr__(self): 647 return '<%s: %s>' % (self.__class__.__name__, self.id) 648 649 def __iter__(self): 650 return iter(self.cells.values()) 651 652 @property 653 def status(self): 654 column_names = self.table._meta.status_columns 655 if column_names: 656 statuses = dict((column_name, self.cells[column_name].status) for 657 column_name in column_names) 658 return self.table.calculate_row_status(statuses) 659 660 @property 661 def status_class(self): 662 column_names = self.table._meta.status_columns 663 if column_names: 664 return self.table.get_row_status_class(self.status) 665 return '' 666 667 def render(self): 668 return render_to_string("horizon/common/_data_table_row.html", 669 {"row": self}) 670 671 def get_cells(self): 672 """Returns the bound cells for this row in order.""" 673 return list(self.cells.values()) 674 675 def get_ajax_update_url(self): 676 table_url = self.table.get_absolute_url() 677 marker_name = self.table._meta.pagination_param 678 marker = self.table.request.GET.get(marker_name, None) 679 if not marker: 680 marker_name = self.table._meta.prev_pagination_param 681 marker = self.table.request.GET.get(marker_name, None) 682 request_params = [ 683 ("action", self.ajax_action_name), 684 ("table", self.table.name), 685 ("obj_id", self.table.get_object_id(self.datum)), 686 ] 687 if marker: 688 request_params.append((marker_name, marker)) 689 params = urlencode(collections.OrderedDict(request_params)) 690 return "%s?%s" % (table_url, params) 691 692 def can_be_selected(self, datum): 693 """Determines whether the row can be selected. 694 695 By default if multiselect enabled return True. 696 You can remove the checkbox after an ajax update here if required. 697 """ 698 return True 699 700 def get_data(self, request, obj_id): 701 """Fetches the updated data for the row based on the given object ID. 702 703 Must be implemented by a subclass to allow AJAX updating. 704 """ 705 return {} 706 707 708class Cell(html.HTMLElement): 709 """Represents a single cell in the table.""" 710 711 def __init__(self, datum, column, row, attrs=None, classes=None): 712 self.classes = classes or getattr(self, "classes", []) 713 super().__init__() 714 self.attrs.update(attrs or {}) 715 716 self.datum = datum 717 self.column = column 718 self.row = row 719 self.wrap_list = column.wrap_list 720 self.inline_edit_available = self.column.update_action is not None 721 # initialize the update action if available 722 if self.inline_edit_available: 723 self.update_action = self.column.update_action() 724 self.attrs['data-cell-name'] = column.name 725 self.attrs['data-update-url'] = self.get_ajax_update_url() 726 self.inline_edit_mod = False 727 # add tooltip to cells if the truncate variable is set 728 if column.truncate: 729 # NOTE(tsufiev): trying to pull cell raw data out of datum for 730 # those columns where truncate is False leads to multiple errors 731 # in unit tests 732 data = getattr(datum, column.name, '') or '' 733 data = encoding.force_text(data) 734 if len(data) > column.truncate: 735 self.attrs['data-toggle'] = 'tooltip' 736 self.attrs['title'] = data 737 if settings.INTEGRATION_TESTS_SUPPORT: 738 self.attrs['data-selenium'] = data 739 self.data = self.get_data(datum, column, row) 740 741 def get_data(self, datum, column, row): 742 """Fetches the data to be displayed in this cell.""" 743 table = row.table 744 if column.auto == "multi_select": 745 data = "" 746 if row.can_be_selected(datum): 747 widget = ThemableCheckboxInput(check_test=lambda value: False) 748 # Convert value to string to avoid accidental type conversion 749 data = widget.render('object_ids', 750 table.get_object_id(datum), 751 {'class': 'table-row-multi-select'}) 752 table._data_cache[column][table.get_object_id(datum)] = data 753 elif column.auto == "form_field": 754 widget = column.form_field 755 if issubclass(widget.__class__, forms.Field): 756 widget = widget.widget 757 758 widget_name = "%s__%s" % \ 759 (column.name, 760 table.get_object_id(datum)) 761 762 # Create local copy of attributes, so it don't change column 763 # class form_field_attributes 764 form_field_attributes = {} 765 form_field_attributes.update(column.form_field_attributes) 766 # Adding id of the input so it pairs with label correctly 767 form_field_attributes['id'] = widget_name 768 769 if (template.defaultfilters.urlize in column.filters or 770 template.defaultfilters.yesno in column.filters): 771 data = widget.render(widget_name, 772 column.get_raw_data(datum), 773 form_field_attributes) 774 else: 775 data = widget.render(widget_name, 776 column.get_data(datum), 777 form_field_attributes) 778 table._data_cache[column][table.get_object_id(datum)] = data 779 elif column.auto == "actions": 780 data = table.render_row_actions(datum) 781 table._data_cache[column][table.get_object_id(datum)] = data 782 else: 783 data = column.get_data(datum) 784 if column.cell_attributes_getter: 785 cell_attributes = column.cell_attributes_getter(data) or {} 786 self.attrs.update(cell_attributes) 787 return data 788 789 def __repr__(self): 790 return '<%s: %s, %s>' % (self.__class__.__name__, 791 self.column.name, 792 self.row.id) 793 794 @property 795 def id(self): 796 return ("%s__%s" % (self.column.name, 797 self.row.table.get_object_id(self.datum))) 798 799 @property 800 def value(self): 801 """Returns a formatted version of the data for final output. 802 803 This takes into consideration the 804 :attr:`~horizon.tables.Column.link`` and 805 :attr:`~horizon.tables.Column.empty_value` 806 attributes. 807 """ 808 try: 809 data = self.column.get_data(self.datum) 810 if data is None: 811 if callable(self.column.empty_value): 812 data = self.column.empty_value(self.datum) 813 else: 814 data = self.column.empty_value 815 except Exception as e: 816 raise template.TemplateSyntaxError from e 817 818 if self.url and not self.column.auto == "form_field": 819 link_attrs = ' '.join(['%s="%s"' % (k, v) for (k, v) in 820 self.column.link_attrs.items()]) 821 # Escape the data inside while allowing our HTML to render 822 data = mark_safe('<a href="%s" %s>%s</a>' % ( 823 (escape(self.url), 824 link_attrs, 825 escape(data)))) 826 return data 827 828 @property 829 def url(self): 830 if self.column.link: 831 url = self.column.get_link_url(self.datum) 832 if url: 833 return url 834 else: 835 return None 836 837 @property 838 def status(self): 839 """Gets the status for the column based on the cell's data.""" 840 # Deal with status column mechanics based in this cell's data 841 if hasattr(self, '_status'): 842 # pylint: disable=access-member-before-definition 843 return self._status 844 845 if self.column.status or \ 846 self.column.name in self.column.table._meta.status_columns: 847 # returns the first matching status found 848 data_status_lower = str( 849 self.column.get_raw_data(self.datum)).lower() 850 for status_name, status_value in self.column.status_choices: 851 if str(status_name).lower() == data_status_lower: 852 self._status = status_value 853 return self._status 854 self._status = None 855 return self._status 856 857 def get_status_class(self, status): 858 """Returns a css class name determined by the status value.""" 859 if status is True: 860 return "status_up" 861 if status is False: 862 return "status_down" 863 return "warning" 864 865 def get_default_classes(self): 866 """Returns a flattened string of the cell's CSS classes.""" 867 if not self.url: 868 self.column.classes = [cls for cls in self.column.classes 869 if cls != "anchor"] 870 column_class_string = self.column.get_final_attrs().get('class', "") 871 classes = set(column_class_string.split(" ")) 872 if self.column.status: 873 classes.add(self.get_status_class(self.status)) 874 875 if self.inline_edit_available: 876 classes.add("inline_edit_available") 877 878 return list(classes) 879 880 def get_ajax_update_url(self): 881 column = self.column 882 table_url = column.table.get_absolute_url() 883 params = urlencode(collections.OrderedDict([ 884 ("action", self.row.ajax_cell_action_name), 885 ("table", column.table.name), 886 ("cell_name", column.name), 887 ("obj_id", column.table.get_object_id(self.datum)) 888 ])) 889 890 return "%s?%s" % (table_url, params) 891 892 @property 893 def update_allowed(self): 894 """Determines whether update of given cell is allowed. 895 896 Calls allowed action of defined UpdateAction of the Column. 897 """ 898 return self.update_action.allowed(self.column.table.request, 899 self.datum, 900 self) 901 902 def render(self): 903 return render_to_string("horizon/common/_data_table_cell.html", 904 {"cell": self}) 905 906 907class DataTableOptions(object): 908 """Contains options for :class:`.DataTable` objects. 909 910 .. attribute:: name 911 912 A short name or slug for the table. 913 914 .. attribute:: verbose_name 915 916 A more verbose name for the table meant for display purposes. 917 918 .. attribute:: columns 919 920 A list of column objects or column names. Controls ordering/display 921 of the columns in the table. 922 923 .. attribute:: table_actions 924 925 A list of action classes derived from the 926 :class:`~horizon.tables.Action` class. These actions will handle tasks 927 such as bulk deletion, etc. for multiple objects at once. 928 929 .. attribute:: table_actions_menu 930 931 A list of action classes similar to ``table_actions`` except these 932 will be displayed in a menu instead of as individual buttons. Actions 933 from this list will take precedence over actions from the 934 ``table_actions`` list. 935 936 .. attribute:: table_actions_menu_label 937 938 A label of a menu button for ``table_actions_menu``. The default is 939 "Actions" or "More Actions" depending on ``table_actions``. 940 941 .. attribute:: row_actions 942 943 A list similar to ``table_actions`` except tailored to appear for 944 each row. These actions act on a single object at a time. 945 946 .. attribute:: actions_column 947 948 Boolean value to control rendering of an additional column containing 949 the various actions for each row. Defaults to ``True`` if any actions 950 are specified in the ``row_actions`` option. 951 952 .. attribute:: multi_select 953 954 Boolean value to control rendering of an extra column with checkboxes 955 for selecting multiple objects in the table. Defaults to ``True`` if 956 any actions are specified in the ``table_actions`` option. 957 958 .. attribute:: filter 959 960 Boolean value to control the display of the "filter" search box 961 in the table actions. By default it checks whether or not an instance 962 of :class:`.FilterAction` is in ``table_actions``. 963 964 .. attribute:: template 965 966 String containing the template which should be used to render the 967 table. Defaults to ``"horizon/common/_data_table.html"``. 968 969 .. attribute:: row_actions_dropdown_template 970 971 String containing the template which should be used to render the 972 row actions dropdown. Defaults to 973 ``"horizon/common/_data_table_row_actions_dropdown.html"``. 974 975 .. attribute:: row_actions_row_template 976 977 String containing the template which should be used to render the 978 row actions. Defaults to 979 ``"horizon/common/_data_table_row_actions_row.html"``. 980 981 .. attribute:: table_actions_template 982 983 String containing the template which should be used to render the 984 table actions. Defaults to 985 ``"horizon/common/_data_table_table_actions.html"``. 986 987 .. attribute:: context_var_name 988 989 The name of the context variable which will contain the table when 990 it is rendered. Defaults to ``"table"``. 991 992 .. attribute:: prev_pagination_param 993 994 The name of the query string parameter which will be used when 995 paginating backward in this table. When using multiple tables in a 996 single view this will need to be changed to differentiate between the 997 tables. Default: ``"prev_marker"``. 998 999 .. attribute:: pagination_param 1000 1001 The name of the query string parameter which will be used when 1002 paginating forward in this table. When using multiple tables in a 1003 single view this will need to be changed to differentiate between the 1004 tables. Default: ``"marker"``. 1005 1006 .. attribute:: status_columns 1007 1008 A list or tuple of column names which represents the "state" 1009 of the data object being represented. 1010 1011 If ``status_columns`` is set, when the rows are rendered the value 1012 of this column will be used to add an extra class to the row in 1013 the form of ``"status_up"`` or ``"status_down"`` for that row's 1014 data. 1015 1016 The row status is used by other Horizon components to trigger tasks 1017 such as dynamic AJAX updating. 1018 1019 .. attribute:: cell_class 1020 1021 The class which should be used for rendering the cells of this table. 1022 Optional. Default: :class:`~horizon.tables.Cell`. 1023 1024 .. attribute:: row_class 1025 1026 The class which should be used for rendering the rows of this table. 1027 Optional. Default: :class:`~horizon.tables.Row`. 1028 1029 .. attribute:: column_class 1030 1031 The class which should be used for handling the columns of this table. 1032 Optional. Default: :class:`~horizon.tables.Column`. 1033 1034 .. attribute:: css_classes 1035 1036 A custom CSS class or classes to add to the ``<table>`` tag of the 1037 rendered table, for when the particular table requires special styling. 1038 Default: ``""``. 1039 1040 .. attribute:: mixed_data_type 1041 1042 A toggle to indicate if the table accepts two or more types of data. 1043 Optional. Default: ``False`` 1044 1045 .. attribute:: data_types 1046 1047 A list of data types that this table would accept. Default to be an 1048 empty list, but if the attribute ``mixed_data_type`` is set to 1049 ``True``, then this list must have at least one element. 1050 1051 .. attribute:: data_type_name 1052 1053 The name of an attribute to assign to data passed to the table when it 1054 accepts mix data. Default: ``"_table_data_type"`` 1055 1056 .. attribute:: footer 1057 1058 Boolean to control whether or not to show the table's footer. 1059 Default: ``True``. 1060 1061 .. attribute:: hidden_title 1062 1063 Boolean to control whether or not to show the table's title. 1064 Default: ``True``. 1065 1066 .. attribute:: permissions 1067 1068 A list of permission names which this table requires in order to be 1069 displayed. Defaults to an empty list (``[]``). 1070 """ 1071 def __init__(self, options): 1072 self.name = getattr(options, 'name', self.__class__.__name__) 1073 verbose_name = (getattr(options, 'verbose_name', None) or 1074 self.name.title()) 1075 self.verbose_name = verbose_name 1076 self.columns = getattr(options, 'columns', None) 1077 self.status_columns = getattr(options, 'status_columns', []) 1078 self.table_actions = getattr(options, 'table_actions', []) 1079 self.row_actions = getattr(options, 'row_actions', []) 1080 self.table_actions_menu = getattr(options, 'table_actions_menu', []) 1081 self.table_actions_menu_label = getattr(options, 1082 'table_actions_menu_label', 1083 None) 1084 self.cell_class = getattr(options, 'cell_class', Cell) 1085 self.row_class = getattr(options, 'row_class', Row) 1086 self.column_class = getattr(options, 'column_class', Column) 1087 self.css_classes = getattr(options, 'css_classes', '') 1088 self.prev_pagination_param = getattr(options, 1089 'prev_pagination_param', 1090 'prev_marker') 1091 self.pagination_param = getattr(options, 'pagination_param', 'marker') 1092 self.browser_table = getattr(options, 'browser_table', None) 1093 self.footer = getattr(options, 'footer', True) 1094 self.hidden_title = getattr(options, 'hidden_title', True) 1095 self.no_data_message = getattr(options, 1096 "no_data_message", 1097 _("No items to display.")) 1098 self.permissions = getattr(options, 'permissions', []) 1099 1100 # Set self.filter if we have any FilterActions 1101 filter_actions = [action for action in self.table_actions if 1102 issubclass(action, FilterAction)] 1103 batch_actions = [action for action in self.table_actions if 1104 issubclass(action, BatchAction)] 1105 if len(filter_actions) > 1: 1106 raise NotImplementedError("Multiple filter actions are not " 1107 "currently supported.") 1108 self.filter = getattr(options, 'filter', len(filter_actions) > 0) 1109 if len(filter_actions) == 1: 1110 self._filter_action = filter_actions.pop() 1111 else: 1112 self._filter_action = None 1113 1114 self.template = getattr(options, 1115 'template', 1116 'horizon/common/_data_table.html') 1117 self.row_actions_dropdown_template = \ 1118 getattr(options, 1119 'row_actions_dropdown_template', 1120 'horizon/common/_data_table_row_actions_dropdown.html') 1121 self.row_actions_row_template = \ 1122 getattr(options, 1123 'row_actions_row_template', 1124 'horizon/common/_data_table_row_actions_row.html') 1125 self.table_actions_template = \ 1126 getattr(options, 1127 'table_actions_template', 1128 'horizon/common/_data_table_table_actions.html') 1129 self.context_var_name = getattr(options, 1130 'context_var_name', 1131 'table') 1132 self.actions_column = getattr(options, 1133 'actions_column', 1134 len(self.row_actions) > 0) 1135 self.multi_select = getattr(options, 1136 'multi_select', 1137 len(batch_actions) > 0) 1138 1139 # Set runtime table defaults; not configurable. 1140 self.has_prev_data = False 1141 self.has_more_data = False 1142 1143 # Set mixed data type table attr 1144 self.mixed_data_type = getattr(options, 'mixed_data_type', False) 1145 self.data_types = getattr(options, 'data_types', []) 1146 1147 # If the data_types has more than 2 elements, set mixed_data_type 1148 # to True automatically. 1149 if len(self.data_types) > 1: 1150 self.mixed_data_type = True 1151 1152 # However, if the mixed_data_type is set to True manually and 1153 # the data_types is empty, raise an error. 1154 if self.mixed_data_type and len(self.data_types) <= 1: 1155 raise ValueError("If mixed_data_type is set to True in class %s, " 1156 "data_types should has more than one types" % 1157 self.name) 1158 1159 self.data_type_name = getattr(options, 1160 'data_type_name', 1161 "_table_data_type") 1162 1163 self.filter_first_message = \ 1164 getattr(options, 1165 'filter_first_message', 1166 _('Please specify a search criteria first.')) 1167 1168 1169class DataTableMetaclass(type): 1170 """Metaclass to add options to DataTable class and collect columns.""" 1171 def __new__(cls, name, bases, attrs): 1172 # Process options from Meta 1173 class_name = name 1174 dt_attrs = {} 1175 dt_attrs["_meta"] = opts = DataTableOptions(attrs.get("Meta", None)) 1176 1177 # Gather columns; this prevents the column from being an attribute 1178 # on the DataTable class and avoids naming conflicts. 1179 columns = [] 1180 for attr_name, obj in attrs.items(): 1181 if isinstance(obj, (opts.column_class, Column)): 1182 column_instance = attrs[attr_name] 1183 column_instance.name = attr_name 1184 column_instance.classes.append('normal_column') 1185 columns.append((attr_name, column_instance)) 1186 else: 1187 dt_attrs[attr_name] = obj 1188 columns.sort(key=lambda x: x[1].creation_counter) 1189 1190 # Iterate in reverse to preserve final order 1191 for base in reversed(bases): 1192 if hasattr(base, 'base_columns'): 1193 columns[0:0] = base.base_columns.items() 1194 dt_attrs['base_columns'] = collections.OrderedDict(columns) 1195 1196 # If the table is in a ResourceBrowser, the column number must meet 1197 # these limits because of the width of the browser. 1198 if opts.browser_table == "navigation" and len(columns) > 3: 1199 raise ValueError("You can assign at most three columns to %s." 1200 % class_name) 1201 if opts.browser_table == "content" and len(columns) > 2: 1202 raise ValueError("You can assign at most two columns to %s." 1203 % class_name) 1204 1205 if opts.columns: 1206 # Remove any columns that weren't declared if we're being explicit 1207 # NOTE: we're iterating a COPY of the list here! 1208 for column_data in columns[:]: 1209 if column_data[0] not in opts.columns: 1210 columns.pop(columns.index(column_data)) 1211 # Re-order based on declared columns 1212 columns.sort(key=lambda x: dt_attrs['_meta'].columns.index(x[0])) 1213 # Add in our auto-generated columns 1214 if opts.multi_select and opts.browser_table != "navigation": 1215 multi_select = opts.column_class("multi_select", 1216 verbose_name="", 1217 auto="multi_select") 1218 multi_select.classes.append('multi_select_column') 1219 columns.insert(0, ("multi_select", multi_select)) 1220 if opts.actions_column: 1221 actions_column = opts.column_class("actions", 1222 verbose_name=_("Actions"), 1223 auto="actions") 1224 actions_column.classes.append('actions_column') 1225 columns.append(("actions", actions_column)) 1226 # Store this set of columns internally so we can copy them per-instance 1227 dt_attrs['_columns'] = collections.OrderedDict(columns) 1228 1229 # Gather and register actions for later access since we only want 1230 # to instantiate them once. 1231 # (list() call gives deterministic sort order, which sets don't have.) 1232 actions = list(set(opts.row_actions) | set(opts.table_actions) | 1233 set(opts.table_actions_menu)) 1234 actions.sort(key=attrgetter('name')) 1235 actions_dict = collections.OrderedDict([(action.name, action()) 1236 for action in actions]) 1237 dt_attrs['base_actions'] = actions_dict 1238 if opts._filter_action: 1239 # Replace our filter action with the instantiated version 1240 opts._filter_action = actions_dict[opts._filter_action.name] 1241 1242 # Create our new class! 1243 return type.__new__(cls, name, bases, dt_attrs) 1244 1245 1246class DataTable(object, metaclass=DataTableMetaclass): 1247 """A class which defines a table with all data and associated actions. 1248 1249 .. attribute:: name 1250 1251 String. Read-only access to the name specified in the 1252 table's Meta options. 1253 1254 .. attribute:: multi_select 1255 1256 Boolean. Read-only access to whether or not this table 1257 should display a column for multi-select checkboxes. 1258 1259 .. attribute:: data 1260 1261 Read-only access to the data this table represents. 1262 1263 .. attribute:: filtered_data 1264 1265 Read-only access to the data this table represents, filtered by 1266 the :meth:`~horizon.tables.FilterAction.filter` method of the table's 1267 :class:`~horizon.tables.FilterAction` class (if one is provided) 1268 using the current request's query parameters. 1269 """ 1270 1271 def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): 1272 self.request = request 1273 self.data = data 1274 self.kwargs = kwargs 1275 self._needs_form_wrapper = needs_form_wrapper 1276 self._no_data_message = self._meta.no_data_message 1277 self.breadcrumb = None 1278 self.current_item_id = None 1279 self.permissions = self._meta.permissions 1280 self.needs_filter_first = False 1281 self._filter_first_message = self._meta.filter_first_message 1282 1283 # Create a new set 1284 columns = [] 1285 for key, _column in self._columns.items(): 1286 if _column.allowed(request): 1287 column = copy.copy(_column) 1288 column.table = self 1289 columns.append((key, column)) 1290 self.columns = collections.OrderedDict(columns) 1291 self._populate_data_cache() 1292 1293 # Associate these actions with this table 1294 for action in self.base_actions.values(): 1295 action.associate_with_table(self) 1296 1297 self.needs_summary_row = any([col.summation 1298 for col in self.columns.values()]) 1299 # For multi-process, we need to set the multi_column to be visible 1300 # or hidden each time. 1301 # Example: first process the multi_column visible but second 1302 # process the column is hidden. Updating row by ajax will 1303 # make the bug#1799151 1304 if request.GET.get('action') == 'row_update': 1305 bound_actions = self.get_table_actions() 1306 batch_actions = [action for action in bound_actions 1307 if isinstance(action, BatchAction)] 1308 self.set_multiselect_column_visibility(bool(batch_actions)) 1309 1310 def __str__(self): 1311 return str(self._meta.verbose_name) 1312 1313 def __repr__(self): 1314 return '<%s: %s>' % (self.__class__.__name__, self._meta.name) 1315 1316 @property 1317 def name(self): 1318 return self._meta.name 1319 1320 @property 1321 def footer(self): 1322 return self._meta.footer 1323 1324 @property 1325 def multi_select(self): 1326 return self._meta.multi_select 1327 1328 @property 1329 def filtered_data(self): 1330 # This function should be using django.utils.functional.cached_property 1331 # decorator, but unfortunately due to bug in Django 1332 # https://code.djangoproject.com/ticket/19872 it would make it fail 1333 # when being mocked in tests. 1334 # TODO(amotoki): Check if this trick is still required. 1335 if not hasattr(self, '_filtered_data'): 1336 self._filtered_data = self.data 1337 if self._meta.filter and self._meta._filter_action: 1338 action = self._meta._filter_action 1339 filter_string = self.get_filter_string() 1340 filter_field = self.get_filter_field() 1341 request_method = self.request.method 1342 needs_preloading = (not filter_string and 1343 request_method == 'GET' and 1344 action.needs_preloading) 1345 valid_method = (request_method == action.method) 1346 not_api_filter = (filter_string and 1347 not action.is_api_filter(filter_field)) 1348 1349 if valid_method or needs_preloading or not_api_filter: 1350 if self._meta.mixed_data_type: 1351 self._filtered_data = action.data_type_filter( 1352 self, self.data, filter_string) 1353 else: 1354 self._filtered_data = action.filter( 1355 self, self.data, filter_string) 1356 return self._filtered_data 1357 1358 def slugify_name(self): 1359 return str(slugify(self._meta.name)) 1360 1361 def get_filter_string(self): 1362 """Get the filter string value. 1363 1364 For 'server' type filters this is saved in the session so that 1365 it gets persisted across table loads. For other filter types 1366 this is obtained from the POST dict. 1367 """ 1368 filter_action = self._meta._filter_action 1369 param_name = filter_action.get_param_name() 1370 filter_string = '' 1371 if filter_action.filter_type == 'server': 1372 filter_string = self.request.session.get(param_name, '') 1373 else: 1374 filter_string = self.request.POST.get(param_name, '') 1375 return filter_string 1376 1377 def get_filter_field(self): 1378 """Get the filter field value used for 'server' type filters. 1379 1380 This is the value from the filter action's list of filter choices. 1381 """ 1382 filter_action = self._meta._filter_action 1383 param_name = '%s_field' % filter_action.get_param_name() 1384 filter_field = self.request.session.get(param_name, '') 1385 return filter_field 1386 1387 def _populate_data_cache(self): 1388 self._data_cache = {} 1389 # Set up hash tables to store data points for each column 1390 for column in self.get_columns(): 1391 self._data_cache[column] = {} 1392 1393 def _filter_action(self, action, request, datum=None): 1394 try: 1395 # Catch user errors in permission functions here 1396 row_matched = True 1397 if self._meta.mixed_data_type: 1398 row_matched = action.data_type_matched(datum) 1399 return action._allowed(request, datum) and row_matched 1400 except AssertionError: 1401 # don't trap mox exceptions (which subclass AssertionError) 1402 # when testing! 1403 # TODO(amotoki): Check if this trick is still required. 1404 raise 1405 except Exception: 1406 LOG.exception("Error while checking action permissions.") 1407 return None 1408 1409 def is_browser_table(self): 1410 if self._meta.browser_table: 1411 return True 1412 return False 1413 1414 def render(self): 1415 """Renders the table using the template from the table options.""" 1416 table_template = template.loader.get_template(self._meta.template) 1417 extra_context = {self._meta.context_var_name: self, 1418 'hidden_title': self._meta.hidden_title} 1419 return table_template.render(extra_context, self.request) 1420 1421 def get_absolute_url(self): 1422 """Returns the canonical URL for this table. 1423 1424 This is used for the POST action attribute on the form element 1425 wrapping the table. In many cases it is also useful for redirecting 1426 after a successful action on the table. 1427 1428 For convenience it defaults to the value of 1429 ``request.get_full_path()`` with any query string stripped off, 1430 e.g. the path at which the table was requested. 1431 """ 1432 return self.request.get_full_path().partition('?')[0] 1433 1434 def get_full_url(self): 1435 """Returns the full URL path for this table. 1436 1437 This is used for the POST action attribute on the form element 1438 wrapping the table. We use this method to persist the 1439 pagination marker. 1440 1441 """ 1442 return self.request.get_full_path() 1443 1444 def get_empty_message(self): 1445 """Returns the message to be displayed when there is no data.""" 1446 return self._no_data_message 1447 1448 def get_filter_first_message(self): 1449 """Return the message to be displayed first in the filter. 1450 1451 when the user needs to provide a search criteria first 1452 before loading any data. 1453 """ 1454 return self._filter_first_message 1455 1456 def get_object_by_id(self, lookup): 1457 """Returns the data object whose ID matches ``loopup`` parameter. 1458 1459 The data object is looked up from the table's dataset and 1460 the data which matches the ``lookup`` parameter specified. 1461 An error will be raised if the match is not a single data object. 1462 1463 We will convert the object id and ``lookup`` to unicode before 1464 comparison. 1465 1466 Uses :meth:`~horizon.tables.DataTable.get_object_id` internally. 1467 """ 1468 if not isinstance(lookup, str): 1469 lookup = str(lookup) 1470 matches = [] 1471 for datum in self.data: 1472 obj_id = self.get_object_id(datum) 1473 if not isinstance(obj_id, str): 1474 obj_id = str(obj_id) 1475 if obj_id == lookup: 1476 matches.append(datum) 1477 if len(matches) > 1: 1478 raise ValueError("Multiple matches were returned for that id: %s." 1479 % matches) 1480 if not matches: 1481 raise exceptions.Http302(self.get_absolute_url(), 1482 _('No match returned for the id "%s".') 1483 % lookup) 1484 return matches[0] 1485 1486 @property 1487 def has_actions(self): 1488 """Indicates whether there are any available actions on this table. 1489 1490 Returns a boolean value. 1491 """ 1492 if not self.base_actions: 1493 return False 1494 return any(self.get_table_actions()) or any(self._meta.row_actions) 1495 1496 @property 1497 def needs_form_wrapper(self): 1498 """Returns if this table should be rendered wrapped in a ``<form>`` tag. 1499 1500 Returns a boolean value. 1501 """ 1502 # If needs_form_wrapper is explicitly set, defer to that. 1503 if self._needs_form_wrapper is not None: 1504 return self._needs_form_wrapper 1505 # Otherwise calculate whether or not we need a form element. 1506 return self.has_actions 1507 1508 def get_table_actions(self): 1509 """Returns a list of the action instances for this table.""" 1510 button_actions = [self.base_actions[action.name] for action in 1511 self._meta.table_actions if 1512 action not in self._meta.table_actions_menu] 1513 menu_actions = [self.base_actions[action.name] for 1514 action in self._meta.table_actions_menu] 1515 bound_actions = button_actions + menu_actions 1516 return [action for action in bound_actions if 1517 self._filter_action(action, self.request)] 1518 1519 def get_row_actions(self, datum): 1520 """Returns a list of the action instances for a specific row.""" 1521 bound_actions = [] 1522 for action in self._meta.row_actions: 1523 # Copy to allow modifying properties per row 1524 bound_action = copy.copy(self.base_actions[action.name]) 1525 bound_action.attrs = copy.copy(bound_action.attrs) 1526 bound_action.datum = datum 1527 # Remove disallowed actions. 1528 if not self._filter_action(bound_action, 1529 self.request, 1530 datum): 1531 continue 1532 # Hook for modifying actions based on data. No-op by default. 1533 bound_action.update(self.request, datum) 1534 # Pre-create the URL for this link with appropriate parameters 1535 if issubclass(bound_action.__class__, LinkAction): 1536 bound_action.bound_url = bound_action.get_link_url(datum) 1537 bound_actions.append(bound_action) 1538 return bound_actions 1539 1540 def set_multiselect_column_visibility(self, visible=True): 1541 """hide checkbox column if no current table action is allowed.""" 1542 if not self.multi_select: 1543 return 1544 select_column = list(self.columns.values())[0] 1545 # Try to find if the hidden class need to be 1546 # removed or added based on visible flag. 1547 hidden_found = 'hidden' in select_column.classes 1548 if hidden_found and visible: 1549 select_column.classes.remove('hidden') 1550 elif not hidden_found and not visible: 1551 select_column.classes.append('hidden') 1552 1553 def render_table_actions(self): 1554 """Renders the actions specified in ``Meta.table_actions``.""" 1555 template_path = self._meta.table_actions_template 1556 table_actions_template = template.loader.get_template(template_path) 1557 bound_actions = self.get_table_actions() 1558 batch_actions = [action for action in bound_actions 1559 if isinstance(action, BatchAction)] 1560 extra_context = {"table_actions": bound_actions, 1561 "table_actions_buttons": [], 1562 "table_actions_menu": []} 1563 if self._meta.filter and ( 1564 self._filter_action(self._meta._filter_action, self.request)): 1565 extra_context["filter"] = self._meta._filter_action 1566 for action in bound_actions: 1567 if action.__class__ in self._meta.table_actions_menu: 1568 extra_context['table_actions_menu'].append(action) 1569 elif action != extra_context.get('filter'): 1570 extra_context['table_actions_buttons'].append(action) 1571 if self._meta.table_actions_menu_label: 1572 extra_context['table_actions_menu_label'] = \ 1573 self._meta.table_actions_menu_label 1574 self.set_multiselect_column_visibility(bool(batch_actions)) 1575 return table_actions_template.render(extra_context, self.request) 1576 1577 def render_row_actions(self, datum, row=False): 1578 """Renders the actions specified in ``Meta.row_actions``. 1579 1580 The actions are rendered using the current row data. 1581 If `row` is True, the actions are rendered in a row 1582 of buttons. Otherwise they are rendered in a dropdown box. 1583 """ 1584 if row: 1585 template_path = self._meta.row_actions_row_template 1586 else: 1587 template_path = self._meta.row_actions_dropdown_template 1588 1589 row_actions_template = template.loader.get_template(template_path) 1590 bound_actions = self.get_row_actions(datum) 1591 extra_context = {"row_actions": bound_actions, 1592 "row_id": self.get_object_id(datum)} 1593 return row_actions_template.render(extra_context, self.request) 1594 1595 @staticmethod 1596 def parse_action(action_string): 1597 """Parses the ``action_string`` parameter sent back with the POST data. 1598 1599 By default this parses a string formatted as 1600 ``{{ table_name }}__{{ action_name }}__{{ row_id }}`` and returns 1601 each of the pieces. The ``row_id`` is optional. 1602 """ 1603 if action_string: 1604 bits = action_string.split(STRING_SEPARATOR) 1605 table = bits[0] 1606 action = bits[1] 1607 try: 1608 object_id = STRING_SEPARATOR.join(bits[2:]) 1609 if object_id == '': 1610 object_id = None 1611 except IndexError: 1612 object_id = None 1613 return table, action, object_id 1614 1615 def take_action(self, action_name, obj_id=None, obj_ids=None): 1616 """Locates the appropriate action and routes the object data to it. 1617 1618 The action should return an HTTP redirect if successful, 1619 or a value which evaluates to ``False`` if unsuccessful. 1620 """ 1621 # See if we have a list of ids 1622 obj_ids = obj_ids or self.request.POST.getlist('object_ids') 1623 action = self.base_actions.get(action_name, None) 1624 if not action or action.method != self.request.method: 1625 # We either didn't get an action or we're being hacked. Goodbye. 1626 return None 1627 1628 # Meanwhile, back in Gotham... 1629 if not action.requires_input or obj_id or obj_ids: 1630 if obj_id: 1631 obj_id = self.sanitize_id(obj_id) 1632 if obj_ids: 1633 obj_ids = [self.sanitize_id(i) for i in obj_ids] 1634 # Single handling is easy 1635 if not action.handles_multiple: 1636 response = action.single(self, self.request, obj_id) 1637 # Otherwise figure out what to pass along 1638 else: 1639 # Preference given to a specific id, since that implies 1640 # the user selected an action for just one row. 1641 if obj_id: 1642 obj_ids = [obj_id] 1643 response = action.multiple(self, self.request, obj_ids) 1644 return response 1645 if action and action.requires_input and not (obj_id or obj_ids): 1646 messages.info(self.request, 1647 _("Please select a row before taking that action.")) 1648 return None 1649 1650 @classmethod 1651 def check_handler(cls, request): 1652 """Determine whether the request should be handled by this table.""" 1653 if request.method == "POST" and "action" in request.POST: 1654 table, action, obj_id = cls.parse_action(request.POST["action"]) 1655 elif "table" in request.GET and "action" in request.GET: 1656 table = request.GET["table"] 1657 action = request.GET["action"] 1658 obj_id = request.GET.get("obj_id", None) 1659 else: 1660 table = action = obj_id = None 1661 return table, action, obj_id 1662 1663 def maybe_preempt(self): 1664 """Determine whether the request should be handled in earlier phase. 1665 1666 It determines the request should be handled by a preemptive action 1667 on this table or by an AJAX row update before loading any data. 1668 """ 1669 request = self.request 1670 table_name, action_name, obj_id = self.check_handler(request) 1671 1672 if table_name == self.name: 1673 # Handle AJAX row updating. 1674 new_row = self._meta.row_class(self) 1675 1676 if new_row.ajax and new_row.ajax_action_name == action_name: 1677 try: 1678 datum = new_row.get_data(request, obj_id) 1679 if self.get_object_id(datum) == self.current_item_id: 1680 self.selected = True 1681 new_row.classes.append('current_selected') 1682 new_row.load_cells(datum) 1683 error = False 1684 except Exception: 1685 datum = None 1686 error = exceptions.handle(request, ignore=True) 1687 if request.is_ajax(): 1688 if not error: 1689 return HttpResponse(new_row.render()) 1690 return HttpResponse(status=error.status_code) 1691 elif new_row.ajax_cell_action_name == action_name: 1692 # inline edit of the cell actions 1693 return self.inline_edit_handle(request, table_name, 1694 action_name, obj_id, 1695 new_row) 1696 1697 preemptive_actions = [action for action in 1698 self.base_actions.values() if action.preempt] 1699 if action_name: 1700 for action in preemptive_actions: 1701 if action.name == action_name: 1702 handled = self.take_action(action_name, obj_id) 1703 if handled: 1704 return handled 1705 return None 1706 1707 def inline_edit_handle(self, request, table_name, action_name, obj_id, 1708 new_row): 1709 """Inline edit handler. 1710 1711 Showing form or handling update by POST of the cell. 1712 """ 1713 try: 1714 cell_name = request.GET['cell_name'] 1715 datum = new_row.get_data(request, obj_id) 1716 # TODO(lsmola) extract load cell logic to Cell and load 1717 # only 1 cell. This is kind of ugly. 1718 if request.GET.get('inline_edit_mod') == "true": 1719 new_row.table.columns[cell_name].auto = "form_field" 1720 inline_edit_mod = True 1721 else: 1722 inline_edit_mod = False 1723 1724 # Load the cell and set the inline_edit_mod. 1725 new_row.load_cells(datum) 1726 cell = new_row.cells[cell_name] 1727 cell.inline_edit_mod = inline_edit_mod 1728 1729 # If not allowed, neither edit mod or updating is allowed. 1730 if not cell.update_allowed: 1731 datum_display = (self.get_object_display(datum) or "N/A") 1732 LOG.info('Permission denied to Update Action: "%s"', 1733 datum_display) 1734 return HttpResponse(status=401) 1735 # If it is post request, we are updating the cell. 1736 if request.method == "POST": 1737 return self.inline_update_action(request, 1738 datum, 1739 cell, 1740 obj_id, 1741 cell_name) 1742 1743 error = False 1744 except Exception: 1745 datum = None 1746 error = exceptions.handle(request, ignore=True) 1747 if request.is_ajax(): 1748 if not error: 1749 return HttpResponse(cell.render()) 1750 return HttpResponse(status=error.status_code) 1751 1752 def inline_update_action(self, request, datum, cell, obj_id, cell_name): 1753 """Handling update by POST of the cell.""" 1754 new_cell_value = request.POST.get( 1755 cell_name + '__' + obj_id, None) 1756 if issubclass(cell.column.form_field.__class__, 1757 forms.Field): 1758 try: 1759 # using Django Form Field to parse the 1760 # right value from POST and to validate it 1761 new_cell_value = ( 1762 cell.column.form_field.clean( 1763 new_cell_value)) 1764 cell.update_action.action( 1765 self.request, datum, obj_id, cell_name, new_cell_value) 1766 response = { 1767 'status': 'updated', 1768 'message': '' 1769 } 1770 return HttpResponse( 1771 json.dumps(response), 1772 status=200, 1773 content_type="application/json") 1774 1775 except core_exceptions.ValidationError: 1776 # if there is a validation error, I will 1777 # return the message to the client 1778 exc_type, exc_value, exc_traceback = ( 1779 sys.exc_info()) 1780 response = { 1781 'status': 'validation_error', 1782 'message': ' '.join(exc_value.messages)} 1783 return HttpResponse( 1784 json.dumps(response), 1785 status=400, 1786 content_type="application/json") 1787 1788 def maybe_handle(self): 1789 """Handles table actions if needed. 1790 1791 It determines whether the request should be handled by any action on 1792 this table after data has been loaded. 1793 """ 1794 request = self.request 1795 table_name, action_name, obj_id = self.check_handler(request) 1796 if table_name == self.name and action_name: 1797 action_names = [action.name for action in 1798 self.base_actions.values() if not action.preempt] 1799 # do not run preemptive actions here 1800 if action_name in action_names: 1801 return self.take_action(action_name, obj_id) 1802 return None 1803 1804 def sanitize_id(self, obj_id): 1805 """Override to modify an incoming obj_id to match existing API. 1806 1807 It is used to modify an incoming obj_id (used in Horizon) 1808 to the data type or format expected by the API. 1809 """ 1810 return obj_id 1811 1812 def get_object_id(self, datum): 1813 """Returns the identifier for the object this row will represent. 1814 1815 By default this returns an ``id`` attribute on the given object, 1816 but this can be overridden to return other values. 1817 1818 .. warning:: 1819 1820 Make sure that the value returned is a unique value for the id 1821 otherwise rendering issues can occur. 1822 """ 1823 return datum.id 1824 1825 def get_object_display_key(self, datum): 1826 return 'name' 1827 1828 def get_object_display(self, datum): 1829 """Returns a display name that identifies this object. 1830 1831 By default, this returns a ``name`` attribute from the given object, 1832 but this can be overridden to return other values. 1833 """ 1834 display_key = self.get_object_display_key(datum) 1835 return getattr(datum, display_key, None) 1836 1837 def has_prev_data(self): 1838 """Returns a boolean value indicating whether there is previous data. 1839 1840 Returns True if there is previous data available to this table 1841 from the source (generally an API). 1842 1843 The method is largely meant for internal use, but if you want to 1844 override it to provide custom behavior you can do so at your own risk. 1845 """ 1846 return self._meta.has_prev_data 1847 1848 def has_more_data(self): 1849 """Returns a boolean value indicating whether there is more data. 1850 1851 Returns True if there is more data available to this table 1852 from the source (generally an API). 1853 1854 The method is largely meant for internal use, but if you want to 1855 override it to provide custom behavior you can do so at your own risk. 1856 """ 1857 return self._meta.has_more_data 1858 1859 def get_prev_marker(self): 1860 """Returns the identifier for the first object in the current data set. 1861 1862 The return value will be used as marker/limit-based paging in the API. 1863 """ 1864 return http.urlquote_plus(self.get_object_id(self.data[0])) \ 1865 if self.data else '' 1866 1867 def get_marker(self): 1868 """Returns the identifier for the last object in the current data set. 1869 1870 The return value will be used as marker/limit-based paging in the API. 1871 """ 1872 return http.urlquote_plus(self.get_object_id(self.data[-1])) \ 1873 if self.data else '' 1874 1875 def get_prev_pagination_string(self): 1876 """Returns the query parameter string to paginate to the prev page.""" 1877 return "=".join([self._meta.prev_pagination_param, 1878 self.get_prev_marker()]) 1879 1880 def get_pagination_string(self): 1881 """Returns the query parameter string to paginate to the next page.""" 1882 return "=".join([self._meta.pagination_param, self.get_marker()]) 1883 1884 def calculate_row_status(self, statuses): 1885 """Returns a boolean value determining the overall row status. 1886 1887 It is detremined based on the dictionary of column name 1888 to status mappings passed in. 1889 1890 By default, it uses the following logic: 1891 1892 #. If any statuses are ``False``, return ``False``. 1893 #. If no statuses are ``False`` but any or ``None``, return ``None``. 1894 #. If all statuses are ``True``, return ``True``. 1895 1896 This provides the greatest protection against false positives without 1897 weighting any particular columns. 1898 1899 The ``statuses`` parameter is passed in as a dictionary mapping 1900 column names to their statuses in order to allow this function to 1901 be overridden in such a way as to weight one column's status over 1902 another should that behavior be desired. 1903 """ 1904 values = statuses.values() 1905 if any([status is False for status in values]): 1906 return False 1907 if any([status is None for status in values]): 1908 return None 1909 return True 1910 1911 def get_row_status_class(self, status): 1912 """Returns a css class name determined by the status value. 1913 1914 This class name is used to indicate the status of the rows in the table 1915 if any ``status_columns`` have been specified. 1916 """ 1917 if status is True: 1918 return "status_up" 1919 if status is False: 1920 return "status_down" 1921 return "warning" 1922 1923 def get_columns(self): 1924 """Returns this table's columns including auto-generated ones.""" 1925 return self.columns.values() 1926 1927 def get_rows(self): 1928 """Return the row data for this table broken out by columns.""" 1929 rows = [] 1930 try: 1931 for datum in self.filtered_data: 1932 row = self._meta.row_class(self, datum) 1933 if self.get_object_id(datum) == self.current_item_id: 1934 self.selected = True 1935 row.classes.append('current_selected') 1936 rows.append(row) 1937 except Exception as e: 1938 # Exceptions can be swallowed at the template level here, 1939 # re-raising as a TemplateSyntaxError makes them visible. 1940 LOG.exception("Error while rendering table rows.") 1941 raise template.TemplateSyntaxError from e 1942 1943 return rows 1944 1945 def css_classes(self): 1946 """Returns the additional CSS class to be added to <table> tag.""" 1947 return self._meta.css_classes 1948