OpenStack Dashboard (Horizon)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

base.py 75KB

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