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 15from collections import defaultdict 16 17from django import shortcuts 18 19from horizon import views 20 21from horizon.templatetags.horizon import has_permissions 22 23 24class MultiTableMixin(object): 25 """A generic mixin which provides methods for handling DataTables.""" 26 data_method_pattern = "get_%s_data" 27 28 def __init__(self, *args, **kwargs): 29 super().__init__(*args, **kwargs) 30 self.table_classes = getattr(self, "table_classes", []) 31 self._data = {} 32 self._tables = {} 33 self._data_methods = defaultdict(list) 34 self.get_data_methods(self.table_classes, self._data_methods) 35 36 def _get_data_dict(self): 37 if not self._data: 38 for table in self.table_classes: 39 data = [] 40 name = table._meta.name 41 func_list = self._data_methods.get(name, []) 42 for func in func_list: 43 data.extend(func()) 44 self._data[name] = data 45 return self._data 46 47 def get_data_methods(self, table_classes, methods): 48 for table in table_classes: 49 name = table._meta.name 50 if table._meta.mixed_data_type: 51 for data_type in table._meta.data_types: 52 func = self.check_method_exist(self.data_method_pattern, 53 data_type) 54 if func: 55 type_name = table._meta.data_type_name 56 methods[name].append(self.wrap_func(func, 57 type_name, 58 data_type)) 59 else: 60 func = self.check_method_exist(self.data_method_pattern, 61 name) 62 if func: 63 methods[name].append(func) 64 65 def wrap_func(self, data_func, type_name, data_type): 66 def final_data(): 67 data = data_func() 68 self.assign_type_string(data, type_name, data_type) 69 return data 70 return final_data 71 72 def check_method_exist(self, func_pattern="%s", *names): 73 func_name = func_pattern % names 74 func = getattr(self, func_name, None) 75 if not func or not callable(func): 76 cls_name = self.__class__.__name__ 77 raise NotImplementedError("You must define a %s method " 78 "in %s." % (func_name, cls_name)) 79 return func 80 81 def assign_type_string(self, data, type_name, data_type): 82 for datum in data: 83 setattr(datum, type_name, data_type) 84 85 def get_tables(self): 86 if not self.table_classes: 87 raise AttributeError('You must specify one or more DataTable ' 88 'classes for the "table_classes" attribute ' 89 'on %s.' % self.__class__.__name__) 90 if not self._tables: 91 for table in self.table_classes: 92 if not has_permissions(self.request.user, 93 table._meta): 94 continue 95 func_name = "get_%s_table" % table._meta.name 96 table_func = getattr(self, func_name, None) 97 if table_func is None: 98 tbl = table(self.request, **self.kwargs) 99 else: 100 tbl = table_func(self, self.request, **self.kwargs) 101 self._tables[table._meta.name] = tbl 102 return self._tables 103 104 def get_context_data(self, **kwargs): 105 context = super().get_context_data(**kwargs) 106 tables = self.get_tables() 107 for name, table in tables.items(): 108 context["%s_table" % name] = table 109 return context 110 111 def has_prev_data(self, table): 112 return False 113 114 def has_more_data(self, table): 115 return False 116 117 def needs_filter_first(self, table): 118 return False 119 120 def handle_table(self, table): 121 name = table.name 122 data = self._get_data_dict() 123 self._tables[name].data = data[table._meta.name] 124 self._tables[name].needs_filter_first = \ 125 self.needs_filter_first(table) 126 self._tables[name]._meta.has_more_data = self.has_more_data(table) 127 self._tables[name]._meta.has_prev_data = self.has_prev_data(table) 128 handled = self._tables[name].maybe_handle() 129 return handled 130 131 def get_server_filter_info(self, request, table=None): 132 if not table: 133 table = self.get_table() 134 filter_action = table._meta._filter_action 135 if filter_action is None or filter_action.filter_type != 'server': 136 return None 137 param_name = filter_action.get_param_name() 138 filter_string = request.POST.get(param_name) 139 filter_string_session = request.session.get(param_name, "") 140 changed = (filter_string is not None and 141 filter_string != filter_string_session) 142 if filter_string is None: 143 filter_string = filter_string_session 144 filter_field_param = param_name + '_field' 145 filter_field = request.POST.get(filter_field_param) 146 filter_field_session = request.session.get(filter_field_param) 147 if filter_field is None and filter_field_session is not None: 148 filter_field = filter_field_session 149 filter_info = { 150 'action': filter_action, 151 'value_param': param_name, 152 'value': filter_string, 153 'field_param': filter_field_param, 154 'field': filter_field, 155 'changed': changed 156 } 157 return filter_info 158 159 def handle_server_filter(self, request, table=None): 160 """Update the table server filter information in the session. 161 162 Returns True if the filter has been changed. 163 """ 164 if not table: 165 table = self.get_table() 166 filter_info = self.get_server_filter_info(request, table) 167 if filter_info is None: 168 return False 169 request.session[filter_info['value_param']] = filter_info['value'] 170 if filter_info['field_param']: 171 request.session[filter_info['field_param']] = filter_info['field'] 172 return filter_info['changed'] 173 174 def update_server_filter_action(self, request, table=None): 175 """Update the table server side filter action. 176 177 It is done based on the current filter. The filter info may be stored 178 in the session and this will restore it. 179 """ 180 if not table: 181 table = self.get_table() 182 filter_info = self.get_server_filter_info(request, table) 183 if filter_info is not None: 184 action = filter_info['action'] 185 setattr(action, 'filter_string', filter_info['value']) 186 if filter_info['field_param']: 187 setattr(action, 'filter_field', filter_info['field']) 188 189 190class MultiTableView(MultiTableMixin, views.HorizonTemplateView): 191 """Generic view to handle multiple DataTable classes in a single view. 192 193 Each DataTable class must be a :class:`~horizon.tables.DataTable` class 194 or its subclass. 195 196 Three steps are required to use this view: set the ``table_classes`` 197 attribute with a tuple of the desired 198 :class:`~horizon.tables.DataTable` classes; 199 define a ``get_{{ table_name }}_data`` method for each table class 200 which returns a set of data for that table; and specify a template for 201 the ``template_name`` attribute. 202 """ 203 204 def construct_tables(self): 205 tables = self.get_tables().values() 206 # Early out before data is loaded 207 for table in tables: 208 preempted = table.maybe_preempt() 209 if preempted: 210 return preempted 211 # Load data into each table and check for action handlers 212 for table in tables: 213 handled = self.handle_table(table) 214 if handled: 215 return handled 216 217 # If we didn't already return a response, returning None continues 218 # with the view as normal. 219 return None 220 221 def get(self, request, *args, **kwargs): 222 handled = self.construct_tables() 223 if handled: 224 return handled 225 context = self.get_context_data(**kwargs) 226 return self.render_to_response(context) 227 228 def post(self, request, *args, **kwargs): 229 # GET and POST handling are the same 230 return self.get(request, *args, **kwargs) 231 232 233class DataTableView(MultiTableView): 234 """A class-based generic view to handle basic DataTable processing. 235 236 Three steps are required to use this view: set the ``table_class`` 237 attribute with the desired :class:`~horizon.tables.DataTable` class; 238 define a ``get_data`` method which returns a set of data for the 239 table; and specify a template for the ``template_name`` attribute. 240 241 Optionally, you can override the ``has_more_data`` method to trigger 242 pagination handling for APIs that support it. 243 """ 244 table_class = None 245 context_object_name = 'table' 246 template_name = 'horizon/common/_data_table_view.html' 247 248 def _get_data_dict(self): 249 if not self._data: 250 self.update_server_filter_action(self.request) 251 self._data = {self.table_class._meta.name: self.get_data()} 252 return self._data 253 254 def get_data(self): 255 return [] 256 257 def get_tables(self): 258 if not self._tables: 259 self._tables = {} 260 if has_permissions(self.request.user, 261 self.table_class._meta): 262 self._tables[self.table_class._meta.name] = self.get_table() 263 return self._tables 264 265 def get_table(self): 266 # Note: this method cannot be easily memoized, because get_context_data 267 # uses its cached value directly. 268 if not self.table_class: 269 raise AttributeError('You must specify a DataTable class for the ' 270 '"table_class" attribute on %s.' 271 % self.__class__.__name__) 272 if not hasattr(self, "table"): 273 self.table = self.table_class(self.request, **self.kwargs) 274 return self.table 275 276 def get_context_data(self, **kwargs): 277 context = super().get_context_data(**kwargs) 278 if hasattr(self, "table"): 279 context[self.context_object_name] = self.table 280 return context 281 282 def post(self, request, *args, **kwargs): 283 # If the server side table filter changed then go back to the first 284 # page of data. Otherwise GET and POST handling are the same. 285 if self.handle_server_filter(request): 286 return shortcuts.redirect(self.get_table().get_absolute_url()) 287 return self.get(request, *args, **kwargs) 288 289 def get_filters(self, filters=None, filters_map=None): 290 """Converts a string given by the user into a valid api filter value. 291 292 :filters: Default filter values. 293 {'filter1': filter_value, 'filter2': filter_value} 294 :filters_map: mapping between user input and valid api filter values. 295 {'filter_name':{_("true_value"):True, _("false_value"):False} 296 """ 297 filters = filters or {} 298 filters_map = filters_map or {} 299 filter_action = self.table._meta._filter_action 300 if filter_action: 301 filter_field = self.table.get_filter_field() 302 if filter_action.is_api_filter(filter_field): 303 filter_string = self.table.get_filter_string().strip() 304 if filter_field and filter_string: 305 filter_map = filters_map.get(filter_field, {}) 306 filters[filter_field] = filter_string 307 for k, v in filter_map.items(): 308 # k is django.utils.functional.__proxy__ 309 # and could not be searched in dict 310 if filter_string.lower() == k: 311 filters[filter_field] = v 312 break 313 return filters 314 315 316class MixedDataTableView(DataTableView): 317 """A class-based generic view to handle DataTable with mixed data types. 318 319 Basic usage is the same as DataTableView. 320 321 Three steps are required to use this view: 322 #. Set the ``table_class`` attribute with desired 323 :class:`~horizon.tables.DataTable` class. In the class the 324 ``data_types`` list should have at least two elements. 325 326 #. Define a ``get_{{ data_type }}_data`` method for each data type 327 which returns a set of data for the table. 328 329 #. Specify a template for the ``template_name`` attribute. 330 """ 331 table_class = None 332 context_object_name = 'table' 333 334 def _get_data_dict(self): 335 if not self._data: 336 table = self.table_class 337 self._data = {table._meta.name: []} 338 for data_type in table.data_types: 339 func_name = "get_%s_data" % data_type 340 data_func = getattr(self, func_name, None) 341 if data_func is None: 342 cls_name = self.__class__.__name__ 343 raise NotImplementedError("You must define a %s method " 344 "for %s data type in %s." % 345 (func_name, data_type, cls_name)) 346 data = data_func() 347 self.assign_type_string(data, data_type) 348 self._data[table._meta.name].extend(data) 349 return self._data 350 351 def assign_type_string(self, data, type_string): 352 for datum in data: 353 setattr(datum, self.table_class.data_type_name, 354 type_string) 355 356 def get_table(self): 357 self.table = super().get_table() 358 if not self.table._meta.mixed_data_type: 359 raise AttributeError('You must have at least two elements in ' 360 'the data_types attribute ' 361 'in table %s to use MixedDataTableView.' 362 % self.table._meta.name) 363 return self.table 364 365 366class PagedTableMixin(object): 367 def __init__(self, *args, **kwargs): 368 super().__init__(*args, **kwargs) 369 self._has_prev_data = False 370 self._has_more_data = False 371 372 def has_prev_data(self, table): 373 return self._has_prev_data 374 375 def has_more_data(self, table): 376 return self._has_more_data 377 378 def _get_marker(self): 379 try: 380 meta = self.table_class._meta 381 except AttributeError: 382 meta = self.table_classes[0]._meta 383 prev_marker = self.request.GET.get(meta.prev_pagination_param, None) 384 if prev_marker: 385 return prev_marker, "asc" 386 marker = self.request.GET.get(meta.pagination_param, None) 387 if marker: 388 return marker, "desc" 389 return None, "desc" 390 391 392class PagedTableWithPageMenu(object): 393 def __init__(self, *args, **kwargs): 394 super().__init__(*args, **kwargs) 395 self._current_page = 1 396 self._number_of_pages = 0 397 self._total_of_entries = 0 398 self._page_size = 0 399 400 def handle_table(self, table): 401 name = table.name 402 self._tables[name]._meta.current_page = self.current_page 403 self._tables[name]._meta.number_of_pages = self.number_of_pages 404 return super().handle_table(table) 405 406 def has_prev_data(self, table): 407 return self._current_page > 1 408 409 def has_more_data(self, table): 410 return self._current_page < self._number_of_pages 411 412 def current_page(self, table=None): 413 return self._current_page 414 415 def number_of_pages(self, table=None): 416 return self._number_of_pages 417 418 def current_offset(self, table): 419 return self._current_page * self._page_size + 1 420 421 def get_page_param(self, table): 422 try: 423 meta = self.table_class._meta 424 except AttributeError: 425 meta = self.table_classes[0]._meta 426 427 return meta.pagination_param 428 429 def _get_page_number(self): 430 page_number = self.request.GET.get(self.get_page_param(None), None) 431 if page_number: 432 return int(page_number) 433 return 1 434