1import django_tables2 as tables
2from django.conf import settings
3from django.contrib.auth.models import AnonymousUser
4from django.contrib.contenttypes.fields import GenericForeignKey
5from django.contrib.contenttypes.models import ContentType
6from django.core.exceptions import FieldDoesNotExist
7from django.db.models.fields.related import RelatedField
8from django.urls import reverse
9from django.utils.safestring import mark_safe
10from django_tables2 import RequestConfig
11from django_tables2.data import TableQuerysetData
12from django_tables2.utils import Accessor
13
14from extras.choices import CustomFieldTypeChoices
15from extras.models import CustomField
16from .utils import content_type_name
17from .paginator import EnhancedPaginator, get_paginate_count
18
19
20class BaseTable(tables.Table):
21    """
22    Default table for object lists
23
24    :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
25    """
26    id = tables.Column(
27        linkify=True,
28        verbose_name='ID'
29    )
30
31    class Meta:
32        attrs = {
33            'class': 'table table-hover object-list',
34        }
35
36    def __init__(self, *args, user=None, extra_columns=None, **kwargs):
37        # Add custom field columns
38        obj_type = ContentType.objects.get_for_model(self._meta.model)
39        cf_columns = [
40            (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
41        ]
42        if extra_columns is not None:
43            extra_columns.extend(cf_columns)
44        else:
45            extra_columns = cf_columns
46
47        super().__init__(*args, extra_columns=extra_columns, **kwargs)
48
49        # Set default empty_text if none was provided
50        if self.empty_text is None:
51            self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
52
53        # Hide non-default columns
54        default_columns = getattr(self.Meta, 'default_columns', list())
55        if default_columns:
56            for column in self.columns:
57                if column.name not in default_columns:
58                    self.columns.hide(column.name)
59
60        # Apply custom column ordering for user
61        if user is not None and not isinstance(user, AnonymousUser):
62            selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
63            if selected_columns:
64
65                # Show only persistent or selected columns
66                for name, column in self.columns.items():
67                    if name in ['pk', 'actions', *selected_columns]:
68                        self.columns.show(name)
69                    else:
70                        self.columns.hide(name)
71
72                # Rearrange the sequence to list selected columns first, followed by all remaining columns
73                # TODO: There's probably a more clever way to accomplish this
74                self.sequence = [
75                    *[c for c in selected_columns if c in self.columns.names()],
76                    *[c for c in self.columns.names() if c not in selected_columns]
77                ]
78
79                # PK column should always come first
80                if 'pk' in self.sequence:
81                    self.sequence.remove('pk')
82                    self.sequence.insert(0, 'pk')
83
84                # Actions column should always come last
85                if 'actions' in self.sequence:
86                    self.sequence.remove('actions')
87                    self.sequence.append('actions')
88
89        # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
90        if isinstance(self.data, TableQuerysetData):
91
92            prefetch_fields = []
93            for column in self.columns:
94                if column.visible:
95                    model = getattr(self.Meta, 'model')
96                    accessor = column.accessor
97                    prefetch_path = []
98                    for field_name in accessor.split(accessor.SEPARATOR):
99                        try:
100                            field = model._meta.get_field(field_name)
101                        except FieldDoesNotExist:
102                            break
103                        if isinstance(field, RelatedField):
104                            # Follow ForeignKeys to the related model
105                            prefetch_path.append(field_name)
106                            model = field.remote_field.model
107                        elif isinstance(field, GenericForeignKey):
108                            # Can't prefetch beyond a GenericForeignKey
109                            prefetch_path.append(field_name)
110                            break
111                    if prefetch_path:
112                        prefetch_fields.append('__'.join(prefetch_path))
113            self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
114
115    def _get_columns(self, visible=True):
116        columns = []
117        for name, column in self.columns.items():
118            if column.visible == visible and name not in ['pk', 'actions']:
119                columns.append((name, column.verbose_name))
120        return columns
121
122    @property
123    def available_columns(self):
124        return self._get_columns(visible=False)
125
126    @property
127    def selected_columns(self):
128        return self._get_columns(visible=True)
129
130    @property
131    def objects_count(self):
132        """
133        Return the total number of real objects represented by the Table. This is useful when dealing with
134        prefixes/IP addresses/etc., where some table rows may represent available address space.
135        """
136        if not hasattr(self, '_objects_count'):
137            self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
138        return self._objects_count
139
140
141#
142# Table columns
143#
144
145class ToggleColumn(tables.CheckBoxColumn):
146    """
147    Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
148    """
149    def __init__(self, *args, **kwargs):
150        default = kwargs.pop('default', '')
151        visible = kwargs.pop('visible', False)
152        if 'attrs' not in kwargs:
153            kwargs['attrs'] = {
154                'td': {
155                    'class': 'min-width',
156                },
157                'input': {
158                    'class': 'form-check-input'
159                }
160            }
161        super().__init__(*args, default=default, visible=visible, **kwargs)
162
163    @property
164    def header(self):
165        return mark_safe('<input type="checkbox" class="toggle form-check-input" title="Toggle All" />')
166
167
168class BooleanColumn(tables.Column):
169    """
170    Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
171    character.
172    """
173    def render(self, value):
174        if value:
175            rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
176        elif value is None:
177            rendered = '<span class="text-muted">&mdash;</span>'
178        else:
179            rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
180        return mark_safe(rendered)
181
182    def value(self, value):
183        return str(value)
184
185
186class TemplateColumn(tables.TemplateColumn):
187    """
188    Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
189    """
190    PLACEHOLDER = mark_safe('&mdash;')
191
192    def render(self, *args, **kwargs):
193        ret = super().render(*args, **kwargs)
194        if not ret.strip():
195            return self.PLACEHOLDER
196        return ret
197
198    def value(self, **kwargs):
199        ret = super().value(**kwargs)
200        if ret == self.PLACEHOLDER:
201            return ''
202        return ret
203
204
205class ButtonsColumn(tables.TemplateColumn):
206    """
207    Render edit, delete, and changelog buttons for an object.
208
209    :param model: Model class to use for calculating URL view names
210    :param prepend_content: Additional template content to render in the column (optional)
211    :param return_url_extra: String to append to the return URL (e.g. for specifying a tab) (optional)
212    """
213    buttons = ('changelog', 'edit', 'delete')
214    attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
215    # Note that braces are escaped to allow for string formatting prior to template rendering
216    template_code = """
217    {{% if "changelog" in buttons %}}
218        <a href="{{% url '{app_label}:{model_name}_changelog' pk=record.pk %}}" class="btn btn-outline-dark btn-sm" title="Change log">
219            <i class="mdi mdi-history"></i>
220        </a>
221    {{% endif %}}
222    {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
223        <a href="{{% url '{app_label}:{model_name}_edit' pk=record.pk %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-sm btn-warning" title="Edit">
224            <i class="mdi mdi-pencil"></i>
225        </a>
226    {{% endif %}}
227    {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
228        <a href="{{% url '{app_label}:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-sm btn-danger" title="Delete">
229            <i class="mdi mdi-trash-can-outline"></i>
230        </a>
231    {{% endif %}}
232    """
233
234    def __init__(self, model, *args, buttons=None, prepend_template=None, return_url_extra='', **kwargs):
235        if prepend_template:
236            prepend_template = prepend_template.replace('{', '{{')
237            prepend_template = prepend_template.replace('}', '}}')
238            self.template_code = prepend_template + self.template_code
239
240        template_code = self.template_code.format(
241            app_label=model._meta.app_label,
242            model_name=model._meta.model_name,
243            buttons=buttons
244        )
245
246        super().__init__(template_code=template_code, *args, **kwargs)
247
248        # Exclude from export by default
249        if 'exclude_from_export' not in kwargs:
250            self.exclude_from_export = True
251
252        self.extra_context.update({
253            'buttons': buttons or self.buttons,
254            'return_url_extra': return_url_extra,
255        })
256
257    def header(self):
258        return ''
259
260
261class ChoiceFieldColumn(tables.Column):
262    """
263    Render a ChoiceField value inside a <span> indicating a particular CSS class. This is useful for displaying colored
264    choices. The CSS class is derived by calling .get_FOO_class() on the row record.
265    """
266    def render(self, record, bound_column, value):
267        if value:
268            name = bound_column.name
269            css_class = getattr(record, f'get_{name}_class')()
270            label = getattr(record, f'get_{name}_display')()
271            return mark_safe(
272                f'<span class="badge bg-{css_class}">{label}</span>'
273            )
274        return self.default
275
276    def value(self, value):
277        return value
278
279
280class ContentTypeColumn(tables.Column):
281    """
282    Display a ContentType instance.
283    """
284    def render(self, value):
285        if value is None:
286            return None
287        return content_type_name(value)
288
289    def value(self, value):
290        if value is None:
291            return None
292        return f"{value.app_label}.{value.model}"
293
294
295class ContentTypesColumn(tables.ManyToManyColumn):
296    """
297    Display a list of ContentType instances.
298    """
299    def transform(self, obj):
300        return content_type_name(obj)
301
302
303class ColorColumn(tables.Column):
304    """
305    Display a color (#RRGGBB).
306    """
307    def render(self, value):
308        return mark_safe(
309            f'<span class="color-label" style="background-color: #{value}">&nbsp;</span>'
310        )
311
312    def value(self, value):
313        return f'#{value}'
314
315
316class ColoredLabelColumn(tables.TemplateColumn):
317    """
318    Render a colored label (e.g. for DeviceRoles).
319    """
320    template_code = """
321    {% load helpers %}
322    {% if value %}
323    <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
324      {{ value }}
325    </span>
326    {% else %}
327    &mdash;
328    {% endif %}
329    """
330
331    def __init__(self, *args, **kwargs):
332        super().__init__(template_code=self.template_code, *args, **kwargs)
333
334    def value(self, value):
335        return str(value)
336
337
338class LinkedCountColumn(tables.Column):
339    """
340    Render a count of related objects linked to a filtered URL.
341
342    :param viewname: The view name to use for URL resolution
343    :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
344    :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
345    """
346    def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
347        self.viewname = viewname
348        self.view_kwargs = view_kwargs or {}
349        self.url_params = url_params
350        super().__init__(*args, default=default, **kwargs)
351
352    def render(self, record, value):
353        if value:
354            url = reverse(self.viewname, kwargs=self.view_kwargs)
355            if self.url_params:
356                url += '?' + '&'.join([
357                    f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
358                    for k, v in self.url_params.items()
359                ])
360            return mark_safe(f'<a href="{url}">{value}</a>')
361        return value
362
363    def value(self, value):
364        return value
365
366
367class TagColumn(tables.TemplateColumn):
368    """
369    Display a list of tags assigned to the object.
370    """
371    template_code = """
372    {% for tag in value.all %}
373        {% include 'utilities/templatetags/tag.html' %}
374    {% empty %}
375        <span class="text-muted">&mdash;</span>
376    {% endfor %}
377    """
378
379    def __init__(self, url_name=None):
380        super().__init__(
381            template_code=self.template_code,
382            extra_context={'url_name': url_name}
383        )
384
385    def value(self, value):
386        return ",".join([tag.name for tag in value.all()])
387
388
389class CustomFieldColumn(tables.Column):
390    """
391    Display custom fields in the appropriate format.
392    """
393    def __init__(self, customfield, *args, **kwargs):
394        self.customfield = customfield
395        kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
396        if 'verbose_name' not in kwargs:
397            kwargs['verbose_name'] = customfield.label or customfield.name
398
399        super().__init__(*args, **kwargs)
400
401    def render(self, value):
402        if isinstance(value, list):
403            return ', '.join(v for v in value)
404        elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
405            # Linkify custom URLs
406            return mark_safe(f'<a href="{value}">{value}</a>')
407        return value or self.default
408
409
410class MPTTColumn(tables.TemplateColumn):
411    """
412    Display a nested hierarchy for MPTT-enabled models.
413    """
414    template_code = """
415        {% load helpers %}
416        {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
417        <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
418    """
419
420    def __init__(self, *args, **kwargs):
421        super().__init__(
422            template_code=self.template_code,
423            orderable=False,
424            attrs={'td': {'class': 'text-nowrap'}},
425            *args,
426            **kwargs
427        )
428
429    def value(self, value):
430        return value
431
432
433class UtilizationColumn(tables.TemplateColumn):
434    """
435    Display a colored utilization bar graph.
436    """
437    template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
438
439    def __init__(self, *args, **kwargs):
440        super().__init__(template_code=self.template_code, *args, **kwargs)
441
442    def value(self, value):
443        return f'{value}%'
444
445
446class MarkdownColumn(tables.TemplateColumn):
447    """
448    Render a Markdown string.
449    """
450    template_code = """
451    {% load helpers %}
452    {% if value %}
453      {{ value|render_markdown }}
454    {% else %}
455      &mdash;
456    {% endif %}
457    """
458
459    def __init__(self):
460        super().__init__(
461            template_code=self.template_code
462        )
463
464    def value(self, value):
465        return value
466
467
468#
469# Pagination
470#
471
472def paginate_table(table, request):
473    """
474    Paginate a table given a request context.
475    """
476    paginate = {
477        'paginator_class': EnhancedPaginator,
478        'per_page': get_paginate_count(request)
479    }
480    RequestConfig(request, paginate).configure(table)
481