Coverage for netbox/tables/tables.py: 27%

245 statements  

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

1from copy import deepcopy 

2from functools import cached_property 

3from urllib.parse import urlencode 

4 

5import django_tables2 as tables 

6from django.conf import settings 

7from django.contrib.contenttypes.fields import GenericForeignKey 

8from django.core.exceptions import FieldDoesNotExist 

9from django.db.models.fields.related import RelatedField 

10from django.db.models.fields.reverse_related import ManyToOneRel 

11from django.urls.exceptions import NoReverseMatch 

12from django.utils.safestring import mark_safe 

13from django.utils.translation import gettext_lazy as _ 

14from django_tables2.data import TableQuerysetData 

15from django_tables2.utils import OrderBy 

16 

17from core.models import ObjectType 

18from extras.choices import * 

19from extras.models import CustomField, CustomLink 

20from netbox.constants import EMPTY_TABLE_TEXT 

21from netbox.registry import registry 

22from netbox.tables import columns 

23from utilities.html import highlight 

24from utilities.paginator import EnhancedPaginator, get_paginate_count 

25from utilities.string import title 

26from utilities.views import get_action_url 

27 

28from .template_code import * 

29 

30__all__ = ( 

31 'BaseTable', 

32 'NestedGroupModelTable', 

33 'NetBoxTable', 

34 'OrganizationalModelTable', 

35 'PrimaryModelTable', 

36 'SearchTable', 

37) 

38 

39 

40class BaseTable(tables.Table): 

41 """ 

42 Base table class for NetBox objects. Adds support for: 

43 

44 * User configuration (column preferences) 

45 * Automatic prefetching of related objects 

46 * BS5 styling 

47 

48 :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. 

49 """ 

50 exempt_columns = () 

51 

52 class Meta: 

53 attrs = { 

54 'class': 'table table-hover object-list', 

55 } 

56 

57 # TODO: Remove user kwarg in NetBox v4.7 

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

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

60 

61 # Set default empty_text if none was provided 

62 if self.empty_text is None: 

63 self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural) 

64 

65 def _get_columns(self, visible=True): 

66 columns = [] 

67 for name, column in self.columns.items(): 

68 if column.visible == visible and name not in self.exempt_columns: 

69 columns.append((name, column.verbose_name)) 

70 return columns 

71 

72 @property 

73 def name(self): 

74 return self.__class__.__name__ 

75 

76 @property 

77 def available_columns(self): 

78 return sorted(self._get_columns(visible=False)) 

79 

80 @property 

81 def selected_columns(self): 

82 return self._get_columns(visible=True) 

83 

84 @property 

85 def objects_count(self): 

86 """ 

87 Return the total number of real objects represented by the Table. This is useful when dealing with 

88 prefixes/IP addresses/etc., where some table rows may represent available address space. 

89 """ 

90 if not hasattr(self, '_objects_count'): 

91 self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) 

92 return self._objects_count 

93 

94 def _set_columns(self, selected_columns): 

95 """ 

96 Update the table sequence to display only the named columns and any exempt columns. 

97 """ 

98 # Hide non-selected columns which are not exempt 

99 for column in self.columns: 

100 if column.name not in [*selected_columns, *self.exempt_columns]: 

101 self.columns.hide(column.name) 

102 

103 # Rearrange the sequence to list selected columns first, followed by all remaining columns 

104 # TODO: There's probably a more clever way to accomplish this 

105 self.sequence = [ 

106 *[c for c in selected_columns if c in self.columns.names()], 

107 *[c for c in self.columns.names() if c not in selected_columns] 

108 ] 

109 

110 # PK column should always come first 

111 if 'pk' in self.sequence: 

112 self.sequence.remove('pk') 

113 self.sequence.insert(0, 'pk') 

114 

115 # Actions column should always come last 

116 if 'actions' in self.sequence: 

117 self.sequence.remove('actions') 

118 self.sequence.append('actions') 

119 

120 def _apply_prefetching(self, columns=None): 

121 """ 

122 Dynamically update the table's QuerySet to ensure related fields are pre-fetched. 

123 

124 Args: 

125 columns: An optional iterable of column names for which to apply prefetching, 

126 regardless of visibility. If None, only currently visible columns are used. 

127 """ 

128 if not isinstance(self.data, TableQuerysetData): 

129 return 

130 

131 prefetch_fields = [] 

132 for column in self.columns.iterall(): 

133 if columns is not None: 

134 if column.name not in columns: 

135 continue 

136 elif not column.visible: 

137 # Skip hidden columns 

138 continue 

139 model = getattr(self.Meta, 'model') # Must be called *after* resolving columns 

140 accessor = column.accessor 

141 if accessor.startswith('custom_field_data__'): 

142 # Ignore custom field references 

143 continue 

144 prefetch_path = [] 

145 for field_name in accessor.split(accessor.SEPARATOR): 

146 try: 

147 field = model._meta.get_field(field_name) 

148 except FieldDoesNotExist: 

149 break 

150 if isinstance(field, (RelatedField, ManyToOneRel)): 

151 # Follow ForeignKeys to the related model 

152 prefetch_path.append(field_name) 

153 model = field.remote_field.model 

154 elif isinstance(field, GenericForeignKey): 

155 # Can't prefetch beyond a GenericForeignKey 

156 prefetch_path.append(field_name) 

157 break 

158 if prefetch_path: 

159 prefetch_fields.append('__'.join(prefetch_path)) 

160 self.data.data = self.data.data.prefetch_related(*prefetch_fields) 

161 

162 def _get_custom_field_ordering_columns(self, order_by): 

163 """ 

164 Return the custom field columns among those named by the given ordering. 

165 

166 Args: 

167 order_by: An iterable (or comma-separated string) of order by aliases. 

168 """ 

169 order_by = order_by.split(',') if isinstance(order_by, str) else order_by or () 

170 ordering_columns = [] 

171 for alias in order_by: 

172 name = OrderBy(alias).bare 

173 # Ignore any aliases which django-tables2 will itself discard 

174 if name not in self.columns or not self.columns[name].orderable: 

175 continue 

176 if isinstance(column := self.columns[name].column, columns.CustomFieldColumn): 

177 ordering_columns.append(column) 

178 return ordering_columns 

179 

180 def _apply_ordering_annotations(self, ordering_columns): 

181 """ 

182 Dynamically annotate the table's QuerySet with the expressions needed to sort by the given 

183 custom field columns. These are applied only for the columns actually being ordered by, to 

184 avoid burdening every query with expressions it has no use for. 

185 """ 

186 annotations = {} 

187 for column in ordering_columns: 

188 annotations.update(column.get_ordering_annotation()) 

189 

190 # Skip any annotations already applied, as when the ordering is set more than once 

191 if annotations := { 

192 name: expr for name, expr in annotations.items() 

193 if name not in self.data.data.query.annotations 

194 }: 

195 self.data.data = self.data.data.annotate(**annotations) 

196 

197 def _apply_ordering_tie_breaker(self): 

198 """ 

199 Append the primary key to the table's ordering as a final sort key, so that the ordering is 

200 total. Rows tying on every preceding key -- and every object holding no value for a custom 

201 field ties on both of that column's keys -- are otherwise free to come back in a different 

202 order for each query, which would cause paginated results to skip or repeat rows from one 

203 page to the next. 

204 """ 

205 ordering = self.data.data.query.order_by 

206 if ordering and not any(OrderBy(o).bare in ('pk', 'id') for o in ordering): 

207 self.data.data = self.data.data.order_by(*ordering, 'pk') 

208 

209 @tables.Table.order_by.setter 

210 def order_by(self, value): 

211 """ 

212 Extend the ordering of the table's data with the support needed by custom field columns. 

213 """ 

214 if not isinstance(self.data, TableQuerysetData): 

215 tables.Table.order_by.fset(self, value) 

216 return 

217 

218 if ordering_columns := self._get_custom_field_ordering_columns(value): 

219 self._apply_ordering_annotations(ordering_columns) 

220 tables.Table.order_by.fset(self, value) 

221 if ordering_columns: 

222 self._apply_ordering_tie_breaker() 

223 

224 def configure(self, request): 

225 """ 

226 Configure the table for a specific request context. This performs pagination and records 

227 the user's preferred columns & ordering logic. 

228 """ 

229 columns = None 

230 ordering = None 

231 

232 if request.user.is_authenticated and self.prefixed_order_by_field in request.GET: 

233 if request.GET[self.prefixed_order_by_field]: 

234 # If an ordering has been specified as a query parameter, save it as the 

235 # user's preferred ordering for this table. 

236 ordering = request.GET.getlist(self.prefixed_order_by_field) 

237 request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) 

238 else: 

239 # If the ordering has been set to none (empty), clear any existing preference. 

240 request.user.config.clear(f'tables.{self.name}.ordering', commit=True) 

241 

242 # If the user has a saved preference, apply it 

243 if request.user.is_authenticated and (userconfig := request.user.config): 

244 if columns is None: 

245 columns = userconfig.get(f"tables.{self.name}.columns") 

246 if ordering is None: 

247 ordering = userconfig.get(f"tables.{self.name}.ordering") 

248 if userconfig.get("ui.tables.striping"): 

249 self.attrs['class'] += ' table-striped' 

250 

251 # Fall back to the default columns & ordering 

252 if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'): 

253 columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns') 

254 if columns is None: 

255 columns = getattr(self.Meta, 'default_columns', self.Meta.fields) 

256 

257 self._set_columns(columns) 

258 

259 # Apply column inclusion/exclusion (overrides user preferences) 

260 if columns_param := request.GET.get('include_columns'): 

261 for column_name in columns_param.split(','): 

262 if column_name in self.columns.names(): 

263 self.columns.show(column_name) 

264 if exclude_columns := request.GET.get('exclude_columns'): 

265 exclude_columns = exclude_columns.split(',') 

266 for column_name in exclude_columns: 

267 if column_name in self.columns.names() and column_name not in self.exempt_columns: 

268 self.columns.hide(column_name) 

269 

270 self._apply_prefetching() 

271 if ordering is not None: 

272 self.order_by = ordering 

273 

274 # Paginate the table results 

275 paginate = { 

276 'paginator_class': EnhancedPaginator, 

277 'per_page': get_paginate_count(request) 

278 } 

279 tables.RequestConfig(request, paginate).configure(self) 

280 

281 @property 

282 def configuration(self): 

283 config = { 

284 'columns': ','.join([c[0] for c in self.selected_columns]), 

285 } 

286 if self.order_by: 

287 config['ordering'] = self.order_by 

288 return config 

289 

290 @property 

291 def config_params(self): 

292 if not (model := getattr(self.Meta, 'model', None)): 

293 return None 

294 return urlencode({ 

295 'object_type': ObjectType.objects.get_for_model(model).pk, 

296 'table': self.name, 

297 **self.configuration, 

298 }) 

299 

300 

301class NetBoxTable(BaseTable): 

302 """ 

303 Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes 

304 default columns for: 

305 

306 * PK (row selection) 

307 * ID 

308 * Actions 

309 """ 

310 pk = columns.ToggleColumn( 

311 visible=False 

312 ) 

313 id = tables.Column( 

314 linkify=True, 

315 verbose_name=_('ID') 

316 ) 

317 actions = columns.ActionsColumn() 

318 

319 exempt_columns = ('pk', 'actions') 

320 embedded = False 

321 

322 class Meta(BaseTable.Meta): 

323 pass 

324 

325 def __init__(self, *args, extra_columns=None, **kwargs): 

326 if extra_columns is None: 

327 extra_columns = [] 

328 

329 if registered_columns := registry['tables'].get(self.__class__): 

330 extra_columns.extend([ 

331 # Create a copy to avoid modifying the original Column 

332 (name, deepcopy(column)) for name, column in registered_columns.items() 

333 ]) 

334 

335 # Add columns for custom fields 

336 custom_fields = [ 

337 cf for cf in CustomField.objects.get_for_model(self._meta.model) 

338 if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN 

339 ] 

340 extra_columns.extend([ 

341 (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields 

342 ]) 

343 

344 # Add columns for custom links 

345 object_type = ObjectType.objects.get_for_model(self._meta.model) 

346 custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True) 

347 extra_columns.extend([ 

348 (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links 

349 ]) 

350 

351 super().__init__(*args, extra_columns=extra_columns, **kwargs) 

352 

353 def configure(self, request): 

354 # Remove custom link columns referencing CustomLinks the user cannot view (#22439). 

355 # These columns are added for all enabled CustomLinks in __init__(), before the request 

356 # (and thus the user) is known, so object-level permissions are enforced here instead. 

357 self._restrict_customlink_columns(request.user) 

358 

359 super().configure(request) 

360 

361 def _restrict_customlink_columns(self, user): 

362 """ 

363 Exclude any custom link columns which reference a CustomLink the user does not have 

364 permission to view. 

365 """ 

366 customlinks = { 

367 name: column.column.customlink 

368 for name, column in self.columns.iteritems() 

369 if isinstance(column.column, columns.CustomLinkColumn) 

370 } 

371 if not customlinks: 

372 return 

373 

374 permitted = set( 

375 CustomLink.objects.restrict(user, 'view').filter( 

376 pk__in=[cl.pk for cl in customlinks.values()] 

377 ).values_list('pk', flat=True) 

378 ) 

379 excluded = tuple( 

380 name for name, customlink in customlinks.items() if customlink.pk not in permitted 

381 ) 

382 if excluded: 

383 self.exclude = (*self.exclude, *excluded) 

384 

385 @cached_property 

386 def htmx_url(self): 

387 """ 

388 Return the base HTML request URL for embedded tables. 

389 """ 

390 if self.embedded: 

391 try: 

392 return get_action_url(self._meta.model, action='list') 

393 except NoReverseMatch: 

394 pass 

395 return '' 

396 

397 

398class PrimaryModelTable(NetBoxTable): 

399 owner_group = tables.Column( 

400 accessor='owner__group', 

401 linkify=True, 

402 verbose_name=_('Owner Group'), 

403 ) 

404 owner = tables.Column( 

405 linkify=True, 

406 verbose_name=_('Owner'), 

407 ) 

408 comments = columns.MarkdownColumn( 

409 verbose_name=_('Comments'), 

410 ) 

411 

412 

413class OrganizationalModelTable(NetBoxTable): 

414 owner_group = tables.Column( 

415 accessor='owner__group', 

416 linkify=True, 

417 verbose_name=_('Owner Group'), 

418 ) 

419 owner = tables.Column( 

420 linkify=True, 

421 verbose_name=_('Owner'), 

422 ) 

423 comments = columns.MarkdownColumn( 

424 verbose_name=_('Comments'), 

425 ) 

426 

427 

428class NestedGroupModelTable(NetBoxTable): 

429 owner_group = tables.Column( 

430 accessor='owner__group', 

431 linkify=True, 

432 verbose_name=_('Owner Group'), 

433 ) 

434 owner = tables.Column( 

435 linkify=True, 

436 verbose_name=_('Owner'), 

437 ) 

438 name = columns.TreeColumn( 

439 verbose_name=_('Name'), 

440 linkify=True 

441 ) 

442 parent = tables.Column( 

443 verbose_name=_('Parent'), 

444 linkify=True, 

445 ) 

446 comments = columns.MarkdownColumn( 

447 verbose_name=_('Comments'), 

448 ) 

449 

450 

451class SearchTable(tables.Table): 

452 object_type = columns.ContentTypeColumn( 

453 verbose_name=_('Type'), 

454 order_by="object___meta__verbose_name", 

455 ) 

456 object = tables.Column( 

457 verbose_name=_('Object'), 

458 linkify=True, 

459 order_by=('name', ) 

460 ) 

461 field = tables.Column( 

462 verbose_name=_('Field'), 

463 ) 

464 value = tables.Column( 

465 verbose_name=_('Value'), 

466 ) 

467 attrs = columns.TemplateColumn( 

468 template_code=SEARCH_RESULT_ATTRS, 

469 verbose_name=_('Attributes') 

470 ) 

471 

472 trim_length = 30 

473 

474 class Meta: 

475 attrs = { 

476 'class': 'table table-hover object-list', 

477 } 

478 empty_text = _(EMPTY_TABLE_TEXT) 

479 

480 def __init__(self, data, highlight=None, **kwargs): 

481 self.highlight = highlight 

482 super().__init__(data, **kwargs) 

483 

484 def render_field(self, value, record): 

485 try: 

486 model_field = record.object._meta.get_field(value) 

487 return title(model_field.verbose_name) 

488 except FieldDoesNotExist: 

489 return value 

490 

491 def render_value(self, value): 

492 if not self.highlight: 

493 return value 

494 

495 value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) 

496 

497 return mark_safe(value)