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">—</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('—') 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}"> </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 — 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">—</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 — 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