Coverage for netbox/ui/panels.py: 64%

181 statements  

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

1from django.apps import apps 

2from django.template.loader import render_to_string 

3from django.utils.translation import gettext_lazy as _ 

4 

5from netbox.ui import attrs 

6from netbox.ui.actions import CopyContent 

7from utilities.data import resolve_attr_path 

8from utilities.permissions import get_permission_for_model 

9from utilities.querydict import dict_to_querydict 

10from utilities.string import title 

11from utilities.templatetags.plugins import _get_registered_content 

12from utilities.views import get_viewname 

13 

14__all__ = ( 

15 'CommentsPanel', 

16 'ContextTablePanel', 

17 'JSONPanel', 

18 'NestedGroupObjectPanel', 

19 'ObjectAttributesPanel', 

20 'ObjectPanel', 

21 'ObjectsTablePanel', 

22 'OrganizationalObjectPanel', 

23 'Panel', 

24 'PluginContentPanel', 

25 'RelatedObjectsPanel', 

26 'TemplatePanel', 

27 'TextCodePanel', 

28) 

29 

30 

31# 

32# Base classes 

33# 

34 

35class Panel: 

36 """ 

37 A block of content rendered within an HTML template. 

38 

39 Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each 

40 panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the 

41 top right corner of the card. 

42 

43 Attributes: 

44 template_name (str): The name of the template used to render the panel 

45 

46 Parameters: 

47 title (str): The human-friendly title of the panel 

48 actions (list): An iterable of PanelActions to include in the panel header 

49 """ 

50 template_name = None 

51 title = None 

52 actions = None 

53 

54 def __init__(self, title=None, actions=None): 

55 if title is not None: 

56 self.title = title 

57 if actions is not None: 

58 self.actions = actions 

59 self.actions = list(self.actions) if self.actions else [] 

60 

61 def get_context(self, context): 

62 """ 

63 Return the context data to be used when rendering the panel. 

64 

65 Parameters: 

66 context (dict): The template context 

67 """ 

68 return { 

69 'request': context.get('request'), 

70 'object': context.get('object'), 

71 'perms': context.get('perms'), 

72 'title': self.title, 

73 'actions': self.actions, 

74 'panel_class': self.__class__.__name__, 

75 } 

76 

77 def should_render(self, context): 

78 """ 

79 Determines whether the panel should render on the page. (Default: True) 

80 

81 Parameters: 

82 context (dict): The panel's prepared context (the return value of get_context()) 

83 """ 

84 return True 

85 

86 def render(self, context): 

87 """ 

88 Render the panel as HTML. 

89 

90 Parameters: 

91 context (dict): The template context 

92 """ 

93 ctx = self.get_context(context) 

94 if not self.should_render(ctx): 

95 return '' 

96 return render_to_string(self.template_name, ctx, request=ctx.get('request')) 

97 

98 

99# 

100# Object-specific panels 

101# 

102 

103class ObjectPanel(Panel): 

104 """ 

105 Base class for object-specific panels. 

106 

107 Parameters: 

108 accessor (str): The dotted path in context data to the object being rendered (default: "object") 

109 """ 

110 accessor = 'object' 

111 

112 def __init__(self, accessor=None, **kwargs): 

113 super().__init__(**kwargs) 

114 

115 if accessor is not None: 

116 self.accessor = accessor 

117 

118 def get_context(self, context): 

119 obj = resolve_attr_path(context, self.accessor) 

120 if self.title is not None: 

121 title_ = self.title 

122 elif obj is not None: 

123 title_ = title(obj._meta.verbose_name) 

124 else: 

125 title_ = None 

126 return { 

127 **super().get_context(context), 

128 'title': title_, 

129 'object': obj, 

130 } 

131 

132 

133class ObjectAttributesPanelMeta(type): 

134 

135 def __new__(mcls, name, bases, namespace, **kwargs): 

136 declared = {} 

137 

138 # Walk MRO parents (excluding `object`) for declared attributes 

139 for base in reversed([b for b in bases if hasattr(b, "_attrs")]): 

140 for key, attr in getattr(base, '_attrs', {}).items(): 

141 if key not in declared: 141 ↛ 140line 141 didn't jump to line 140 because the condition on line 141 was always true

142 declared[key] = attr 

143 

144 # Add local declarations in the order they appear in the class body 

145 for key, attr in namespace.items(): 

146 if isinstance(attr, attrs.ObjectAttribute): 

147 declared[key] = attr 

148 

149 namespace['_attrs'] = declared 

150 

151 # Remove Attrs from the class namespace to keep things tidy 

152 local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)] 

153 for key in local_items: 

154 namespace.pop(key) 

155 

156 cls = super().__new__(mcls, name, bases, namespace, **kwargs) 

157 return cls 

158 

159 

160class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): 

161 """ 

162 A panel which displays selected attributes of an object. 

163 

164 Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on 

165 a Django form). Attributes are displayed in the order they are declared. 

166 

167 Note that the `only` and `exclude` parameters are mutually exclusive. 

168 

169 Parameters: 

170 only (list): If specified, only attributes in this list will be displayed 

171 exclude (list): If specified, attributes in this list will be excluded from display 

172 """ 

173 template_name = 'ui/panels/object_attributes.html' 

174 

175 def __init__(self, only=None, exclude=None, **kwargs): 

176 super().__init__(**kwargs) 

177 

178 # Set included/excluded attributes 

179 if only is not None and exclude is not None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 raise ValueError("only and exclude cannot both be specified.") 

181 self.only = only or [] 

182 self.exclude = exclude or [] 

183 

184 @staticmethod 

185 def _name_to_label(name): 

186 """ 

187 Format an attribute's name to be presented as a human-friendly label. 

188 """ 

189 label = name[:1].upper() + name[1:] 

190 label = label.replace('_', ' ') 

191 return _(label) 

192 

193 def get_context(self, context): 

194 # Determine which attributes to display in the panel based on only/exclude args 

195 attr_names = set(self._attrs.keys()) 

196 if self.only: 

197 attr_names &= set(self.only) 

198 elif self.exclude: 

199 attr_names -= set(self.exclude) 

200 

201 ctx = super().get_context(context) 

202 

203 return { 

204 **ctx, 

205 'attrs': [ 

206 { 

207 'label': attr.label or self._name_to_label(name), 

208 'value': attr.render(ctx['object'], { 

209 'name': name, 

210 'perms': ctx['perms'], 

211 'preferences': context.get('preferences', {}), 

212 }), 

213 } for name, attr in self._attrs.items() if name in attr_names 

214 ], 

215 } 

216 

217 

218class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): 

219 """ 

220 An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes. 

221 """ 

222 name = attrs.TextAttr('name', label=_('Name')) 

223 description = attrs.TextAttr('description', label=_('Description')) 

224 

225 

226class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): 

227 """ 

228 An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute. 

229 """ 

230 parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) 

231 name = attrs.TextAttr('name', label=_('Name')) 

232 description = attrs.TextAttr('description', label=_('Description')) 

233 

234 

235class CommentsPanel(ObjectPanel): 

236 """ 

237 A panel which displays comments associated with an object. 

238 

239 Parameters: 

240 field_name (str): The name of the comment field on the object (default: "comments") 

241 """ 

242 template_name = 'ui/panels/comments.html' 

243 title = _('Comments') 

244 

245 def __init__(self, field_name='comments', **kwargs): 

246 super().__init__(**kwargs) 

247 self.field_name = field_name 

248 

249 def get_context(self, context): 

250 ctx = super().get_context(context) 

251 return { 

252 **ctx, 

253 'comments': getattr(ctx['object'], self.field_name, None), 

254 } 

255 

256 

257class JSONPanel(ObjectPanel): 

258 """ 

259 A panel which renders formatted JSON data from an object's JSONField. 

260 

261 Parameters: 

262 field_name (str): The name of the JSON field on the object 

263 copy_button (bool): Set to True (default) to include a copy-to-clipboard button 

264 """ 

265 template_name = 'ui/panels/json.html' 

266 

267 def __init__(self, field_name, copy_button=True, **kwargs): 

268 super().__init__(**kwargs) 

269 self.field_name = field_name 

270 

271 if copy_button: 271 ↛ exitline 271 didn't return from function '__init__' because the condition on line 271 was always true

272 self.actions.append(CopyContent(f'panel_{field_name}')) 

273 

274 def get_context(self, context): 

275 ctx = super().get_context(context) 

276 return { 

277 **ctx, 

278 'data': getattr(ctx['object'], self.field_name, None), 

279 'field_name': self.field_name, 

280 } 

281 

282 

283# 

284# Miscellaneous panels 

285# 

286 

287class RelatedObjectsPanel(Panel): 

288 """ 

289 A panel which displays the types and counts of related objects. 

290 """ 

291 template_name = 'ui/panels/related_objects.html' 

292 title = _('Related Objects') 

293 

294 def get_context(self, context): 

295 return { 

296 **super().get_context(context), 

297 'related_models': context.get('related_models'), 

298 } 

299 

300 

301class ObjectsTablePanel(Panel): 

302 """ 

303 A panel which displays a table of objects (rendered via HTMX). 

304 

305 Parameters: 

306 model (str): The dotted label of the model to be added (e.g. "dcim.site") 

307 filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is 

308 a callable, it will be passed the current template context. 

309 include_columns (list): A list of column names to always display (overrides user preferences) 

310 exclude_columns (list): A list of column names to hide from the table (overrides user preferences) 

311 """ 

312 template_name = 'ui/panels/objects_table.html' 

313 title = None 

314 

315 def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs): 

316 super().__init__(**kwargs) 

317 

318 # Validate the model label format 

319 if '.' not in model: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 raise ValueError(f"Invalid model label: {model}") 

321 self.model_label = model 

322 self.filters = filters or {} 

323 self.include_columns = include_columns or [] 

324 self.exclude_columns = exclude_columns or [] 

325 

326 @property 

327 def model(self): 

328 try: 

329 return apps.get_model(self.model_label) 

330 except LookupError: 

331 raise ValueError(f"Invalid model label: {self.model_label}") 

332 

333 def get_context(self, context): 

334 model = self.model 

335 

336 # If no title is specified, derive one from the model name 

337 panel_title = self.title or title(model._meta.verbose_name_plural) 

338 

339 url_params = { 

340 k: v(context) if callable(v) else v for k, v in self.filters.items() 

341 } 

342 if 'return_url' not in url_params and 'object' in context: 

343 url_params['return_url'] = context['object'].get_absolute_url() 

344 if self.include_columns: 

345 url_params['include_columns'] = ','.join(self.include_columns) 

346 if self.exclude_columns: 

347 url_params['exclude_columns'] = ','.join(self.exclude_columns) 

348 return { 

349 **super().get_context(context), 

350 'title': panel_title, 

351 'viewname': get_viewname(model, 'list'), 

352 'url_params': dict_to_querydict(url_params), 

353 } 

354 

355 def should_render(self, context): 

356 """ 

357 Hide the panel if the user does not have view permission for the panel's model. 

358 """ 

359 request = context.get('request') 

360 if request is None: 

361 return True 

362 

363 return request.user.has_perm(get_permission_for_model(self.model, 'view')) 

364 

365 

366class TemplatePanel(Panel): 

367 """ 

368 A panel which renders custom content using an HTML template. 

369 

370 Parameters: 

371 template_name (str): The name of the template to render 

372 """ 

373 def __init__(self, template_name, **kwargs): 

374 self.template_name = template_name 

375 super().__init__(**kwargs) 

376 

377 def get_context(self, context): 

378 # Pass the entire context to the template, but let the panel's own context take precedence 

379 # for panel-specific variables (title, actions, panel_class) 

380 return { 

381 **context.flatten(), 

382 **super().get_context(context) 

383 } 

384 

385 

386class TextCodePanel(ObjectPanel): 

387 """ 

388 A panel displaying a text field as a pre-formatted code block. 

389 """ 

390 template_name = 'ui/panels/text_code.html' 

391 

392 def __init__(self, field_name, show_sync_warning=False, **kwargs): 

393 super().__init__(**kwargs) 

394 self.field_name = field_name 

395 self.show_sync_warning = show_sync_warning 

396 

397 def get_context(self, context): 

398 ctx = super().get_context(context) 

399 return { 

400 **ctx, 

401 'show_sync_warning': self.show_sync_warning, 

402 'value': getattr(ctx['object'], self.field_name, None), 

403 } 

404 

405 

406class PluginContentPanel(Panel): 

407 """ 

408 A panel which displays embedded plugin content. 

409 

410 Parameters: 

411 method (str): The name of the plugin method to render (e.g. "left_page") 

412 """ 

413 def __init__(self, method, **kwargs): 

414 super().__init__(**kwargs) 

415 self.method = method 

416 

417 def render(self, context): 

418 # Override the default render() method to simply embed rendered plugin content 

419 obj = context.get('object') 

420 return _get_registered_content(obj, self.method, context) 

421 

422 

423class ContextTablePanel(ObjectPanel): 

424 """ 

425 A panel which renders a django-tables2/NetBoxTable instance provided 

426 via the view's extra context. 

427 

428 This is useful when you already have a fully constructed table 

429 (custom queryset, special columns, no list view) and just want to 

430 render it inside a declarative layout panel. 

431 

432 Parameters: 

433 table (str | callable): Either the context key holding the table 

434 (e.g. "vlan_table") or a callable which accepts the template 

435 context and returns a table instance. 

436 """ 

437 template_name = 'ui/panels/context_table.html' 

438 

439 def __init__(self, table, **kwargs): 

440 super().__init__(**kwargs) 

441 self.table = table 

442 

443 def _resolve_table(self, context): 

444 if callable(self.table): 

445 return self.table(context) 

446 return context.get(self.table) 

447 

448 def get_context(self, context): 

449 return { 

450 **super().get_context(context), 

451 'table': self._resolve_table(context), 

452 } 

453 

454 def should_render(self, context): 

455 return context.get('table') is not None