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