Coverage for netbox/tables/columns.py: 35%

402 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import zoneinfo 

2from dataclasses import dataclass 

3from urllib.parse import quote 

4 

5import django_tables2 as tables 

6from django.conf import settings 

7from django.contrib.auth.context_processors import auth 

8from django.contrib.auth.models import AnonymousUser 

9from django.db.models import Case, DateField, DateTimeField, IntegerField, Q, Value, When 

10from django.db.models.fields.json import KeyTextTransform 

11from django.template import Context, Template 

12from django.urls import reverse 

13from django.utils.dateparse import parse_date 

14from django.utils.html import escape, format_html 

15from django.utils.safestring import mark_safe 

16from django.utils.text import format_lazy 

17from django.utils.translation import gettext_lazy as _ 

18from django_tables2.columns import library 

19from django_tables2.utils import Accessor 

20 

21from extras.choices import CustomFieldTypeChoices 

22from utilities.object_types import object_type_identifier, object_type_name 

23from utilities.permissions import get_permission_for_model 

24from utilities.request import get_safe_request_context 

25from utilities.templatetags.builtins.filters import render_markdown 

26from utilities.validators import url_scheme_is_allowed 

27from utilities.views import get_action_url 

28 

29__all__ = ( 

30 'ActionsColumn', 

31 'ArrayColumn', 

32 'BooleanColumn', 

33 'ChoiceFieldColumn', 

34 'ChoicesColumn', 

35 'ColorColumn', 

36 'ColoredLabelColumn', 

37 'ContentTypeColumn', 

38 'ContentTypesColumn', 

39 'CustomFieldColumn', 

40 'CustomLinkColumn', 

41 'DictColumn', 

42 'DistanceColumn', 

43 'DurationColumn', 

44 'LinkedCountColumn', 

45 'MPTTColumn', 

46 'ManyToManyColumn', 

47 'MarkdownColumn', 

48 'TagColumn', 

49 'TemplateColumn', 

50 'ToggleColumn', 

51 'TreeColumn', 

52 'UtilizationColumn', 

53) 

54 

55 

56# 

57# Django-tables2 overrides 

58# 

59 

60@library.register 

61class DateColumn(tables.Column): 

62 """ 

63 Render a datetime.date in ISO 8601 format. 

64 """ 

65 def render(self, value): 

66 if value: 

67 return value.isoformat() 

68 return None 

69 

70 def value(self, value): 

71 if value: 

72 return value.isoformat() 

73 return None 

74 

75 @classmethod 

76 def from_field(cls, field, **kwargs): 

77 if isinstance(field, DateField): 

78 return cls(**kwargs) 

79 return None 

80 

81 

82@library.register 

83class DateTimeColumn(tables.Column): 

84 """ 

85 Render a datetime.datetime in ISO 8601 format. 

86 

87 Args: 

88 timespec: Granularity specification; passed through to datetime.isoformat() 

89 """ 

90 def __init__(self, *args, timespec='seconds', **kwargs): 

91 self.timespec = timespec 

92 super().__init__(*args, **kwargs) 

93 

94 def render(self, value): 

95 if value: 

96 current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) 

97 value = value.astimezone(current_tz) 

98 return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}" 

99 return None 

100 

101 def value(self, value): 

102 if value: 

103 return value.isoformat() 

104 return None 

105 

106 @classmethod 

107 def from_field(cls, field, **kwargs): 

108 if isinstance(field, DateTimeField): 

109 return cls(**kwargs) 

110 return None 

111 

112 

113class DurationColumn(tables.Column): 

114 """ 

115 Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m" 

116 """ 

117 def render(self, value): 

118 ret = '' 

119 if days := value // 1440: 

120 ret += f'{days}d ' 

121 if hours := value % 1440 // 60: 

122 ret += f'{hours}h ' 

123 if minutes := value % 60: 

124 ret += f'{minutes}m' 

125 return ret.strip() 

126 

127 def value(self, value): 

128 return value 

129 

130 

131class ManyToManyColumn(tables.ManyToManyColumn): 

132 """ 

133 Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. 

134 """ 

135 def value(self, value): 

136 items = [self.transform(item) for item in self.filter(value)] 

137 return self.separator.join(items) 

138 

139 

140class TemplateColumn(tables.TemplateColumn): 

141 """ 

142 Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value 

143 is an empty string. 

144 """ 

145 PLACEHOLDER = mark_safe('—') 

146 

147 def __init__(self, export_raw=False, **kwargs): 

148 """ 

149 Args: 

150 export_raw: If true, data export returns the raw field value rather than the rendered template. (Default: 

151 False) 

152 """ 

153 super().__init__(**kwargs) 

154 self.export_raw = export_raw 

155 

156 def render(self, *args, **kwargs): 

157 ret = super().render(*args, **kwargs) 

158 if not ret.strip(): 

159 return self.PLACEHOLDER 

160 return ret 

161 

162 def value(self, **kwargs): 

163 if self.export_raw: 

164 # Skip template rendering and export raw value 

165 return kwargs.get('value') 

166 

167 ret = super().value(**kwargs) 

168 if ret == self.PLACEHOLDER: 

169 return '' 

170 return ret 

171 

172 

173# 

174# Custom columns 

175# 

176 

177class ToggleColumn(tables.CheckBoxColumn): 

178 """ 

179 Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. 

180 """ 

181 def __init__(self, *args, **kwargs): 

182 default = kwargs.pop('default', '') 

183 visible = kwargs.pop('visible', False) 

184 if 'attrs' not in kwargs: 184 ↛ 198line 184 didn't jump to line 198 because the condition on line 184 was always true

185 kwargs['attrs'] = { 

186 'th': { 

187 'class': 'w-1', 

188 'aria-label': _('Select all'), 

189 }, 

190 'td': { 

191 'class': 'w-1', 

192 }, 

193 'input': { 

194 'class': 'form-check-input', 

195 'aria-label': lambda record, value: format_lazy(_('Select {object}'), object=record), 

196 } 

197 } 

198 super().__init__(*args, default=default, visible=visible, **kwargs) 

199 

200 @property 

201 def header(self): 

202 title_text = _('Toggle all') 

203 return format_html( 

204 '<input type="checkbox" class="toggle form-check-input" title="{}" aria-label="{}" />', 

205 title_text, title_text, 

206 ) 

207 

208 

209class BooleanColumn(tables.Column): 

210 """ 

211 Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode 

212 character. 

213 """ 

214 TRUE_MARK = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold"></i></span>') 

215 FALSE_MARK = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>') 

216 EMPTY_MARK = mark_safe('<span class="text-muted">&mdash;</span>') # Placeholder 

217 

218 def __init__(self, *args, true_mark=TRUE_MARK, false_mark=FALSE_MARK, **kwargs): 

219 self.true_mark = true_mark 

220 self.false_mark = false_mark 

221 super().__init__(*args, **kwargs) 

222 

223 def render(self, value): 

224 if value is None: 

225 return self.EMPTY_MARK 

226 if value and self.true_mark: 

227 return self.true_mark 

228 if not value and self.false_mark: 

229 return self.false_mark 

230 return self.EMPTY_MARK 

231 

232 def value(self, value): 

233 return str(value) 

234 

235 

236@dataclass 

237class ActionsItem: 

238 title: str 

239 icon: str 

240 permission: str | None = None 

241 css_class: str | None = 'secondary' 

242 

243 

244class ActionsColumn(tables.Column): 

245 """ 

246 A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include 

247 additional buttons rendered from a template string. 

248 

249 :param actions: The ordered list of dropdown menu items to include 

250 :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown 

251 :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the 

252 direct button link and icon (default: True) 

253 """ 

254 attrs = { 

255 'th': { 

256 'aria-label': _('Actions'), 

257 }, 

258 'td': { 

259 'class': 'text-end text-nowrap noprint p-1' 

260 } 

261 } 

262 empty_values = () 

263 actions = { 

264 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), 

265 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 

266 'changelog': ActionsItem('Changelog', 'history'), 

267 } 

268 

269 def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs): 

270 super().__init__(*args, **kwargs) 

271 

272 self.extra_buttons = extra_buttons 

273 self.split_actions = split_actions 

274 

275 # Determine which actions to enable 

276 self.actions = { 

277 name: self.actions[name] for name in actions 

278 } 

279 

280 def header(self): 

281 return '' 

282 

283 def render(self, record, table, **kwargs): 

284 model = table.Meta.model 

285 

286 # Skip if no actions or extra buttons are defined 

287 if not (self.actions or self.extra_buttons): 

288 return '' 

289 # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs) 

290 if not isinstance(record, model) or not getattr(record, 'pk', None): 

291 return '' 

292 

293 if request := getattr(table, 'context', {}).get('request'): 

294 return_url = request.GET.get('return_url', request.get_full_path()) 

295 url_appendix = f'?return_url={quote(return_url)}' 

296 else: 

297 url_appendix = '' 

298 

299 html = '' 

300 

301 # Compile actions menu 

302 button = None 

303 dropdown_class = 'secondary' 

304 dropdown_links = [] 

305 user = getattr(request, 'user', AnonymousUser()) 

306 for idx, (action, attrs) in enumerate(self.actions.items()): 

307 permission = get_permission_for_model(model, attrs.permission) 

308 if attrs.permission is None or user.has_perm(permission): 

309 url = get_action_url(model, action=action, kwargs={'pk': record.pk}) 

310 

311 # Render a separate button if a) only one action exists, or b) if split_actions is True 

312 if len(self.actions) == 1 or (self.split_actions and idx == 0): 

313 dropdown_class = attrs.css_class 

314 button = ( 

315 f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button" ' 

316 f'aria-label="{attrs.title}">' 

317 f'<i class="mdi mdi-{attrs.icon}"></i></a>' 

318 ) 

319 

320 # Add dropdown menu items 

321 else: 

322 dropdown_links.append( 

323 f'<li><a class="dropdown-item" href="{url}{url_appendix}">' 

324 f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>' 

325 ) 

326 

327 # Create the actions dropdown menu 

328 toggle_text = _('Toggle Dropdown') 

329 if button and dropdown_links: 

330 html += ( 

331 f'<span class="btn-group dropdown">' 

332 f' {button}' 

333 f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" ' 

334 f'style="padding-left: 2px">' 

335 f' <span class="visually-hidden">{toggle_text}</span></a>' 

336 f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>' 

337 f'</span>' 

338 ) 

339 elif button: 

340 html += button 

341 elif dropdown_links: 

342 html += ( 

343 f'<span class="btn-group dropdown">' 

344 f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">' 

345 f' <span class="visually-hidden">{toggle_text}</span></a>' 

346 f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>' 

347 f'</span>' 

348 ) 

349 

350 # Render any extra buttons from template code 

351 if self.extra_buttons: 

352 template = Template(self.extra_buttons) 

353 context = getattr(table, "context", Context()) 

354 context.update({'record': record}) 

355 html = template.render(context) + html 

356 

357 return mark_safe(html) 

358 

359 

360class ChoiceFieldColumn(tables.Column): 

361 """ 

362 Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is 

363 set by the instance's get_FOO_color() method, if defined, or can be overridden by a "color" callable. 

364 """ 

365 DEFAULT_BG_COLOR = 'secondary' 

366 

367 def __init__(self, *args, color=None, **kwargs): 

368 super().__init__(*args, **kwargs) 

369 self.color = color 

370 

371 def render(self, record, bound_column, value): 

372 if value in self.empty_values: 

373 return self.default 

374 

375 # Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color()) 

376 if self.color: 

377 bg_color = self.color(record) 

378 else: 

379 try: 

380 bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR 

381 except AttributeError: 

382 bg_color = self.DEFAULT_BG_COLOR 

383 

384 return mark_safe(f'<span class="badge text-bg-{bg_color}">{value}</span>') 

385 

386 def value(self, value): 

387 return value 

388 

389 

390class ContentTypeColumn(tables.Column): 

391 """ 

392 Display a ContentType instance. 

393 """ 

394 def render(self, value): 

395 if value is None: 

396 return None 

397 return object_type_name(value, include_app=False) 

398 

399 def value(self, value): 

400 if value is None: 

401 return None 

402 return object_type_identifier(value) 

403 

404 

405class ContentTypesColumn(tables.ManyToManyColumn): 

406 """ 

407 Display a list of ContentType instances. 

408 """ 

409 def __init__(self, separator=None, *args, **kwargs): 

410 # Use a line break as the default separator 

411 if separator is None: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true

412 separator = mark_safe('<br />') 

413 super().__init__(separator=separator, *args, **kwargs) 

414 

415 def transform(self, obj): 

416 return object_type_name(obj, include_app=False) 

417 

418 def value(self, value): 

419 return ','.join([ 

420 object_type_identifier(ot) for ot in self.filter(value) 

421 ]) 

422 

423 

424class ColorColumn(tables.Column): 

425 """ 

426 Display an arbitrary color value, specified in RRGGBB format. 

427 """ 

428 def render(self, value): 

429 return mark_safe( 

430 f'<span class="color-label" style="background-color: #{value}">&nbsp;</span>' 

431 ) 

432 

433 def value(self, value): 

434 return f'#{value}' 

435 

436 

437class ColoredLabelColumn(tables.TemplateColumn): 

438 """ 

439 Render a related object as a colored label. The related object must have a `color` attribute (specifying 

440 an RRGGBB value) and a `get_absolute_url()` method. 

441 """ 

442 template_code = """ 

443{% load helpers %} 

444 {% if value %} 

445 <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}"> 

446 <a href="{{ value.get_absolute_url }}">{{ value }}</a> 

447 </span> 

448{% else %} 

449 &mdash; 

450{% endif %} 

451""" 

452 

453 def __init__(self, *args, **kwargs): 

454 super().__init__(template_code=self.template_code, *args, **kwargs) 

455 

456 def value(self, value): 

457 return str(value) 

458 

459 

460class LinkedCountColumn(tables.Column): 

461 """ 

462 Render a count of related objects linked to a filtered URL. 

463 

464 :param viewname: The view name to use for URL resolution 

465 :param view_kwargs: Additional kwargs to pass for URL resolution (optional) 

466 :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional) 

467 """ 

468 def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs): 

469 self.viewname = viewname 

470 self.view_kwargs = view_kwargs or {} 

471 self.url_params = url_params 

472 super().__init__(*args, default=default, **kwargs) 

473 

474 def render(self, record, value): 

475 if value: 

476 url = reverse(self.viewname, kwargs=self.view_kwargs) 

477 if self.url_params: 

478 url += '?' + '&'.join([ 

479 f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}' 

480 for k, v in self.url_params.items() 

481 ]) 

482 return mark_safe(f'<a href="{url}">{escape(value)}</a>') 

483 return value 

484 

485 def value(self, value): 

486 return value 

487 

488 

489class TagColumn(tables.TemplateColumn): 

490 """ 

491 Display a list of Tags assigned to the object. 

492 """ 

493 template_code = """ 

494 {% load helpers %} 

495 {% for tag in value.all %} 

496 {% tag tag url_name %} 

497 {% empty %} 

498 <span class="text-muted">&mdash;</span> 

499 {% endfor %} 

500 """ 

501 

502 def __init__(self, url_name=None): 

503 super().__init__( 

504 orderable=False, 

505 template_code=self.template_code, 

506 extra_context={'url_name': url_name}, 

507 verbose_name=_('Tags'), 

508 ) 

509 

510 def value(self, value): 

511 return ",".join([tag.name for tag in value.all()]) 

512 

513 

514class CustomFieldColumn(tables.Column): 

515 """ 

516 Display custom fields in the appropriate format. 

517 """ 

518 def __init__(self, customfield, *args, **kwargs): 

519 self.customfield = customfield 

520 kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') 

521 if 'verbose_name' not in kwargs: 

522 kwargs['verbose_name'] = customfield.label or customfield.name 

523 # We can't logically sort on FK values 

524 if customfield.type in ( 

525 CustomFieldTypeChoices.TYPE_OBJECT, 

526 CustomFieldTypeChoices.TYPE_MULTIOBJECT 

527 ): 

528 kwargs['orderable'] = False 

529 else: 

530 kwargs.setdefault('order_by', ( 

531 self.unset_alias, 

532 f'custom_field_data__{customfield.name}', 

533 )) 

534 

535 super().__init__(*args, **kwargs) 

536 

537 @property 

538 def unset_alias(self): 

539 """ 

540 Return the name of the annotation which groups together the objects holding no value for 

541 this field (see get_ordering_annotation()). 

542 

543 The annotation is named for the custom field so that ordering by two custom field columns 

544 cannot produce a duplicate alias. Field names are validated to contain only alphanumerics 

545 and underscores, so the alias is always a legal identifier. 

546 """ 

547 return f'_cf_{self.customfield.name}_unset' 

548 

549 def get_ordering_annotation(self): 

550 """ 

551 Return the annotation by which objects holding no value for this field are sorted together, 

552 as the leading sort key for the column. (BaseTable applies it to the queryset when ordering 

553 by this column.) 

554 

555 An object can lack a value either by storing a JSON null or by carrying no key for the 

556 field at all -- the latter being the normal state for objects which predate it, as data is 

557 no longer provisioned onto existing objects (see CustomField.populate_initial_data()). 

558 Postgres sorts those two apart: a JSON null is the lowest jsonb value, whereas a missing 

559 key yields SQL NULL and sorts last, so the "empty" rows would otherwise land at both ends 

560 of the same column. This key (the `empty` lookup covers both states) groups them at one 

561 end, matching how SQL NULLs are ordered for an ordinary column: last when ascending, first 

562 when descending. The column's second sort key then orders by the raw value, so that numeric 

563 and date fields still sort by type rather than lexically. 

564 """ 

565 return { 

566 self.unset_alias: Q(**{f'custom_field_data__{self.customfield.name}__empty': True}) 

567 } 

568 

569 def order(self, queryset, is_descending): 

570 """ 

571 Override get_ordering_annotation()'s default (SQL-standard, direction-coupled) null 

572 placement to honor the custom field's nulls_first attribute instead: the empty group's 

573 position is fixed by admin preference, independent of ascending/descending. Returning 

574 (queryset, True) here signals django-tables2 to use this ordering as-is, bypassing the 

575 generic annotation set up by get_ordering_annotation() (which still runs, but its result 

576 goes unused for this column since only its alias name -- referenced by unset_alias -- 

577 needs to exist, not the SQL-standard placement it would otherwise apply). 

578 

579 A missing key or a JSON null value is extracted as SQL NULL via the ->> (text) operator, 

580 whereas the -> (JSONB) operator used for value ordering treats JSON null as a sortable 

581 value. We therefore annotate an explicit rank to control null placement independently of 

582 JSONB sorting. 

583 

584 Ordering is expressed as plain string keys (not F()-based OrderBy expressions): NetBox's 

585 BaseTable._apply_ordering_tie_breaker() inspects self.data.data.query.order_by afterward 

586 and wraps each entry in django-tables2's own (string-only) OrderBy helper, which raises 

587 TypeError on a raw expression object. 

588 

589 Trade-off: returning (queryset, True) here is django-tables2's signal that this column 

590 has fully handled ordering itself, which takes priority over -- and discards -- any other 

591 columns' sort keys requested in the same multi-column sort (see TableQuerysetData.order_by() 

592 in django_tables2/data.py: the loop applies whichever column's order() last returns 

593 modified=True and returns immediately, never combining it with sibling columns' 

594 contributions). A CustomFieldColumn can therefore not currently be composed with other 

595 columns in a single sort; it is always the sole and final sort key when included. Preserving 

596 nulls_first (an existing, widely-integrated per-field admin setting) was judged to matter 

597 more than gaining composability for this specific column, since django-tables2's per-key 

598 ascending/descending toggle is applied uniformly across an entire order_by tuple and cannot 

599 keep one key's effective placement constant while another flips -- so nulls_first and 

600 multi-column composition cannot both be expressed through the generic annotation mechanism 

601 for the same column. 

602 """ 

603 name = self.customfield.name 

604 text_value = f'_cf_{name}_text' 

605 null_rank = f'_cf_{name}_nullrank' 

606 null_sort, value_sort = (0, 1) if self.customfield.nulls_first else (1, 0) 

607 queryset = queryset.annotate(**{ 

608 text_value: KeyTextTransform(name, 'custom_field_data'), 

609 }).annotate(**{ 

610 null_rank: Case( 

611 When(**{f'{text_value}__isnull': True}, then=Value(null_sort)), 

612 default=Value(value_sort), 

613 output_field=IntegerField(), 

614 ), 

615 }) 

616 value_field = f'custom_field_data__{name}' 

617 ordering = (null_rank, f'-{value_field}' if is_descending else value_field) 

618 return queryset.order_by(*ordering), True 

619 

620 @staticmethod 

621 def _linkify_item(item): 

622 if hasattr(item, 'get_absolute_url'): 

623 return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>' 

624 return escape(item) 

625 

626 def render(self, value): 

627 if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True: 

628 return mark_safe('<i class="mdi mdi-check-bold text-success"></i>') 

629 if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False: 

630 return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>') 

631 if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: 

632 # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against 

633 # dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless 

634 # (relative) value is considered safe. 

635 if url_scheme_is_allowed(value): 

636 return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>') 

637 return escape(value) 

638 if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT: 

639 if value is None: 

640 return self.default 

641 label = self.customfield.get_choice_label(value) 

642 color = self.customfield.get_choice_color(value) 

643 if color: 

644 return mark_safe( 

645 f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>' 

646 ) 

647 return label 

648 if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: 

649 if not value: 

650 return '' 

651 

652 has_color = False 

653 parts = [] 

654 

655 for v in value: 

656 label = self.customfield.get_choice_label(v) 

657 color = self.customfield.get_choice_color(v) 

658 if color: 

659 has_color = True 

660 parts.append((label, color)) 

661 if has_color: 

662 badges = [] 

663 for label, color in parts: 

664 badges.append( 

665 f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>' 

666 ) 

667 return mark_safe(' '.join(badges)) 

668 return ', '.join(label for label, _ in parts) 

669 

670 if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

671 return mark_safe(', '.join( 

672 self._linkify_item(obj) for obj in self.customfield.deserialize(value) 

673 )) 

674 if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value: 

675 return render_markdown(value) 

676 if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value: 

677 return parse_date(value).isoformat() 

678 if value is not None: 

679 obj = self.customfield.deserialize(value) 

680 return mark_safe(self._linkify_item(obj)) 

681 return self.default 

682 

683 def value(self, value): 

684 if isinstance(value, list): 

685 return ','.join(str(v) for v in self.customfield.deserialize(value)) 

686 if value is not None: 

687 return self.customfield.deserialize(value) 

688 return self.default 

689 

690 

691class CustomLinkColumn(tables.Column): 

692 """ 

693 Render a custom link as a table column. 

694 """ 

695 def __init__(self, customlink, *args, **kwargs): 

696 self.customlink = customlink 

697 kwargs.setdefault('accessor', Accessor('pk')) 

698 kwargs.setdefault('orderable', False) 

699 kwargs.setdefault('verbose_name', customlink.name) 

700 

701 super().__init__(*args, **kwargs) 

702 

703 def _render_customlink(self, record, table): 

704 context = { 

705 'object': record, 

706 'debug': settings.DEBUG, 

707 } 

708 if request := getattr(table, 'context', {}).get('request'): 

709 # If the request is available, include a sanitized subset of it as context 

710 context.update({ 

711 'request': get_safe_request_context(request), 

712 **auth(request), 

713 }) 

714 

715 return self.customlink.render(context) 

716 

717 def render(self, record, table, **kwargs): 

718 try: 

719 if rendered := self._render_customlink(record, table): 

720 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>') 

721 except Exception as e: 

722 error_text = _('Error') 

723 return format_html( 

724 '<span class="text-danger" title="{}"><i class="mdi mdi-alert"></i> {}</span>', e, error_text 

725 ) 

726 return '' 

727 

728 def value(self, record, table, **kwargs): 

729 try: 

730 if rendered := self._render_customlink(record, table): 

731 return rendered['link'] 

732 except Exception: 

733 pass 

734 return None 

735 

736 

737class TreeColumn(tables.TemplateColumn): 

738 """ 

739 Display a nested hierarchy for tree-enabled models (Region, Location, etc.). 

740 """ 

741 template_code = """ 

742 {% load helpers %} 

743 {% if not table.order_by %} 

744 {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %} 

745 {% endif %} 

746 <a href="{{ record.get_absolute_url }}">{{ record.name }}</a> 

747 """ 

748 

749 def __init__(self, *args, **kwargs): 

750 super().__init__( 

751 template_code=self.template_code, 

752 attrs={'td': {'class': 'text-nowrap'}}, 

753 *args, 

754 **kwargs 

755 ) 

756 

757 def value(self, value): 

758 return value 

759 

760 

761# Deprecated alias for plugin compatibility; use TreeColumn going forward. 

762# TODO: Remove this in NetBox v5.0 

763MPTTColumn = TreeColumn 

764 

765 

766class UtilizationColumn(tables.TemplateColumn): 

767 """ 

768 Display a colored utilization bar graph. 

769 """ 

770 template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}""" 

771 

772 def __init__(self, *args, **kwargs): 

773 super().__init__(template_code=self.template_code, *args, **kwargs) 

774 

775 def value(self, value): 

776 return f'{value}%' 

777 

778 

779class MarkdownColumn(tables.TemplateColumn): 

780 """ 

781 Render a Markdown string. 

782 """ 

783 template_code = """ 

784 {% if value %} 

785 {{ value|markdown }} 

786 {% else %} 

787 &mdash; 

788 {% endif %} 

789 """ 

790 

791 def __init__(self, **kwargs): 

792 super().__init__( 

793 template_code=self.template_code, 

794 **kwargs, 

795 ) 

796 

797 def value(self, value): 

798 return value 

799 

800 

801class ArrayColumn(tables.Column): 

802 """ 

803 List array items as a comma-separated list. 

804 """ 

805 def __init__(self, *args, max_items=None, func=str, **kwargs): 

806 self.max_items = max_items 

807 self.func = func 

808 super().__init__(*args, **kwargs) 

809 

810 def render(self, value): 

811 omitted_count = 0 

812 

813 # Limit the returned items to the specified maximum number (if any) 

814 if self.max_items: 

815 omitted_count = len(value) - self.max_items 

816 value = value[:self.max_items - 1] 

817 

818 # Apply custom processing function (if any) per item 

819 if self.func: 

820 value = [self.func(v) for v in value] 

821 

822 # Annotate omitted items (if applicable) 

823 if omitted_count > 0: 

824 value.append(f'({omitted_count} more)') 

825 

826 return ', '.join(value) 

827 

828 

829class ChoicesColumn(tables.Column): 

830 """ 

831 Display the human-friendly labels of a set of choices. 

832 """ 

833 def __init__(self, *args, max_items=None, **kwargs): 

834 self.max_items = max_items 

835 super().__init__(*args, **kwargs) 

836 

837 def render(self, value): 

838 omitted_count = 0 

839 value = [v[1] for v in value] 

840 

841 # Limit the returned items to the specified maximum number (if any) 

842 if self.max_items: 

843 omitted_count = len(value) - self.max_items 

844 value = value[:self.max_items - 1] 

845 

846 # Annotate omitted items (if applicable) 

847 if omitted_count > 0: 

848 value.append(f'({omitted_count} more)') 

849 

850 return ', '.join(value) 

851 

852 

853class DistanceColumn(TemplateColumn): 

854 """ 

855 Distance with template code for formatting 

856 """ 

857 template_code = """ 

858 {% load helpers %} 

859 {% display_distance record.distance record.distance_unit record.abs_distance %} 

860 """ 

861 

862 def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs): 

863 super().__init__(template_code=template_code, order_by=order_by, **kwargs) 

864 

865 

866class DictColumn(tables.Column): 

867 """ 

868 Render a dictionary of data in a simple key: value format, one pair per line. 

869 """ 

870 def render(self, value): 

871 output = '<br />'.join([ 

872 f'{escape(k)}: {escape(v)}' for k, v in value.items() 

873 ]) 

874 return mark_safe(output)