Coverage for extras/dashboard/widgets.py: 36%

255 statements  

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

1import logging 

2import uuid 

3from functools import cached_property 

4from hashlib import sha256 

5from urllib.parse import urlencode, urlparse 

6 

7import feedparser 

8import requests 

9from django import forms 

10from django.conf import settings 

11from django.core.cache import cache 

12from django.db.models import Model 

13from django.template.loader import render_to_string 

14from django.urls import NoReverseMatch, resolve 

15from django.utils.translation import gettext as _ 

16 

17from core.models import ObjectType 

18from extras.choices import BookmarkOrderingChoices 

19from netbox.config import get_config 

20from utilities.choices import Choice 

21from utilities.html import clean_html 

22from utilities.object_types import object_type_identifier, object_type_name 

23from utilities.permissions import get_permission_for_model 

24from utilities.proxy import resolve_proxies 

25from utilities.querydict import dict_to_querydict 

26from utilities.templatetags.builtins.filters import render_markdown 

27from utilities.views import get_action_url 

28 

29from .utils import register_widget 

30 

31__all__ = ( 

32 'BookmarksWidget', 

33 'DashboardWidget', 

34 'NoteWidget', 

35 'ObjectCountsWidget', 

36 'ObjectListWidget', 

37 'RSSFeedWidget', 

38 'WidgetConfigForm', 

39) 

40 

41logger = logging.getLogger('netbox.data_backends') 

42 

43 

44def get_object_type_choices(): 

45 return [ 

46 Choice(object_type_identifier(ot), object_type_name(ot)) 

47 for ot in ObjectType.objects.public().order_by('app_label', 'model') 

48 ] 

49 

50 

51def object_list_widget_supports_model(model: Model) -> bool: 

52 """Test whether a model is supported by the ObjectListWidget 

53 

54 In theory there could be more than one reason why a model isn't supported by the 

55 ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL 

56 for the model. Add more tests if more conditions arise. 

57 """ 

58 def can_resolve_model_list_view(model: Model) -> bool: 

59 try: 

60 get_action_url(model, action='list') 

61 return True 

62 except NoReverseMatch: 

63 return False 

64 

65 tests = [ 

66 can_resolve_model_list_view, 

67 ] 

68 

69 return all(test(model) for test in tests) 

70 

71 

72def get_bookmarks_object_type_choices(): 

73 return [ 

74 Choice(object_type_identifier(ot), object_type_name(ot)) 

75 for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') 

76 ] 

77 

78 

79def get_models_from_content_types(content_types): 

80 """ 

81 Return a list of models corresponding to the given content types, identified by natural key. 

82 Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names. 

83 """ 

84 models = [] 

85 for content_type_id in content_types: 

86 app_label, model_name = content_type_id.lower().split('.') 

87 try: 

88 content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) 

89 if content_type.model_class(): 

90 models.append(content_type.model_class()) 

91 else: 

92 logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}") 

93 except ObjectType.DoesNotExist: 

94 logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}") 

95 

96 return models 

97 

98 

99class WidgetConfigForm(forms.Form): 

100 pass 

101 

102 

103class DashboardWidget: 

104 """ 

105 Base class for custom dashboard widgets. 

106 

107 Attributes: 

108 description: A brief, user-friendly description of the widget's function 

109 default_title: The string to show for the widget's title when none has been specified. 

110 default_config: Default configuration parameters, as a dictionary mapping 

111 width: The widget's default width (1 to 12) 

112 height: The widget's default height; the number of rows it consumes 

113 """ 

114 description = None 

115 default_title = None 

116 default_config = {} 

117 width = 4 

118 height = 3 

119 

120 class ConfigForm(WidgetConfigForm): 

121 """ 

122 The widget's configuration form. 

123 """ 

124 pass 

125 

126 def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None): 

127 self.id = id or str(uuid.uuid4()) 

128 self.config = config or self.default_config 

129 self.title = title or self.default_title 

130 self.color = color 

131 if width: 

132 self.width = width 

133 if height: 

134 self.height = height 

135 self.x, self.y = x, y 

136 

137 def __str__(self): 

138 return self.title or self.__class__.__name__ 

139 

140 def set_layout(self, grid_item): 

141 self.width = grid_item.get('w', 1) 

142 self.height = grid_item.get('h', 1) 

143 self.x = grid_item.get('x') 

144 self.y = grid_item.get('y') 

145 

146 def render(self, request): 

147 """ 

148 This method is called to render the widget's content. 

149 

150 Params: 

151 request: The current request 

152 """ 

153 raise NotImplementedError(_("{class_name} must define a render() method.").format( 

154 class_name=self.__class__ 

155 )) 

156 

157 @property 

158 def name(self): 

159 return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}' 

160 

161 @property 

162 def form_data(self): 

163 return { 

164 'title': self.title, 

165 'color': self.color, 

166 'config': self.config, 

167 } 

168 

169 

170@register_widget 

171class NoteWidget(DashboardWidget): 

172 default_title = _('Note') 

173 description = _('Display some arbitrary custom content. Markdown is supported.') 

174 

175 class ConfigForm(WidgetConfigForm): 

176 content = forms.CharField( 

177 widget=forms.Textarea() 

178 ) 

179 

180 def render(self, request): 

181 return render_markdown(self.config.get('content')) 

182 

183 

184@register_widget 

185class ObjectCountsWidget(DashboardWidget): 

186 default_title = _('Object Counts') 

187 description = _('Display a set of NetBox models and the number of objects created for each type.') 

188 template_name = 'extras/dashboard/widgets/objectcounts.html' 

189 

190 class ConfigForm(WidgetConfigForm): 

191 models = forms.MultipleChoiceField( 

192 choices=get_object_type_choices 

193 ) 

194 filters = forms.JSONField( 

195 required=False, 

196 label='Object filters', 

197 help_text=_("Filters to apply when counting the number of objects") 

198 ) 

199 

200 def clean_filters(self): 

201 if data := self.cleaned_data['filters']: 

202 try: 

203 dict(data) 

204 except TypeError: 

205 raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary.")) 

206 return data 

207 

208 def render(self, request): 

209 counts = [] 

210 for model in get_models_from_content_types(self.config['models']): 

211 permission = get_permission_for_model(model, 'view') 

212 if request.user.has_perm(permission): 

213 try: 

214 url = get_action_url(model, action='list') 

215 except NoReverseMatch: 

216 url = None 

217 try: 

218 qs = model.objects.restrict(request.user, 'view') 

219 except AttributeError: 

220 qs = model.objects.all() 

221 # Apply any specified filters 

222 if url and (filters := self.config.get('filters')): 

223 params = dict_to_querydict(filters) 

224 filterset = getattr(resolve(url).func.view_class, 'filterset', None) 

225 qs = filterset(params, qs).qs 

226 url = f'{url}?{params.urlencode()}' 

227 object_count = qs.count 

228 counts.append((model, object_count, url)) 

229 else: 

230 counts.append((model, None, None)) 

231 

232 return render_to_string(self.template_name, { 

233 'counts': counts, 

234 }) 

235 

236 

237@register_widget 

238class ObjectListWidget(DashboardWidget): 

239 default_title = _('Object List') 

240 description = _('Display an arbitrary list of objects.') 

241 template_name = 'extras/dashboard/widgets/objectlist.html' 

242 width = 12 

243 height = 4 

244 

245 class ConfigForm(WidgetConfigForm): 

246 model = forms.ChoiceField( 

247 choices=get_object_type_choices 

248 ) 

249 page_size = forms.IntegerField( 

250 required=False, 

251 min_value=1, 

252 max_value=100, 

253 help_text=_('The default number of objects to display') 

254 ) 

255 url_params = forms.JSONField( 

256 required=False, 

257 label='URL parameters' 

258 ) 

259 

260 def clean_url_params(self): 

261 if data := self.cleaned_data['url_params']: 

262 try: 

263 urlencode(data) 

264 except (TypeError, ValueError): 

265 raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary.")) 

266 return data 

267 

268 def clean_model(self): 

269 if model_info := self.cleaned_data['model']: 

270 app_label, model_name = model_info.split('.') 

271 model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class() 

272 if not object_list_widget_supports_model(model): 

273 raise forms.ValidationError( 

274 _(f"Invalid model selection: {self['model'].data} is not supported.") 

275 ) 

276 

277 return model_info 

278 

279 def render(self, request): 

280 app_label, model_name = self.config['model'].split('.') 

281 model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class() 

282 if not model: 

283 logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}") 

284 return None 

285 

286 # Evaluate user's permission. Note that this controls only whether the HTMX element is 

287 # embedded on the page: The view itself will also evaluate permissions separately. 

288 permission = get_permission_for_model(model, 'view') 

289 has_permission = request.user.has_perm(permission) 

290 

291 try: 

292 htmx_url = get_action_url(model, action='list') 

293 except NoReverseMatch: 

294 htmx_url = None 

295 parameters = self.config.get('url_params') or {} 

296 if page_size := self.config.get('page_size'): 

297 parameters['per_page'] = page_size 

298 parameters['embedded'] = True 

299 

300 if parameters and htmx_url is not None: 

301 try: 

302 htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}' 

303 except ValueError: 

304 pass 

305 return render_to_string(self.template_name, { 

306 'model_name': model_name, 

307 'has_permission': has_permission, 

308 'htmx_url': htmx_url, 

309 }) 

310 

311 

312@register_widget 

313class RSSFeedWidget(DashboardWidget): 

314 default_title = _('RSS Feed') 

315 default_config = { 

316 'max_entries': 10, 

317 'cache_timeout': 3600, # seconds 

318 'request_timeout': 3, # seconds 

319 'requires_internet': True, 

320 } 

321 description = _('Embed an RSS feed from an external website.') 

322 template_name = 'extras/dashboard/widgets/rssfeed.html' 

323 width = 6 

324 height = 4 

325 

326 class ConfigForm(WidgetConfigForm): 

327 feed_url = forms.URLField( 

328 label=_('Feed URL'), 

329 assume_scheme='https' 

330 ) 

331 requires_internet = forms.BooleanField( 

332 label=_('Requires external connection'), 

333 required=False, 

334 ) 

335 max_entries = forms.IntegerField( 

336 min_value=1, 

337 max_value=1000, 

338 help_text=_('The maximum number of objects to display') 

339 ) 

340 cache_timeout = forms.IntegerField( 

341 min_value=600, # 10 minutes 

342 max_value=86400, # 24 hours 

343 help_text=_('How long to stored the cached content (in seconds)') 

344 ) 

345 request_timeout = forms.IntegerField( 

346 min_value=1, 

347 max_value=60, 

348 required=False, 

349 help_text=_('Timeout value for fetching the feed (in seconds)') 

350 ) 

351 

352 def render(self, request): 

353 return render_to_string(self.template_name, { 

354 'url': self.config['feed_url'], 

355 **self.get_feed() 

356 }) 

357 

358 @cached_property 

359 def cache_key(self): 

360 url = self.config['feed_url'] 

361 url_checksum = sha256(url.encode('utf-8')).hexdigest() 

362 # The version segment invalidates entries cached by a pre-sanitization release: such 

363 # entries live under the old key and are never read, so they can't be served unsanitized. 

364 return f'dashboard_rss_2_{url_checksum}' 

365 

366 def get_feed(self): 

367 if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT: 

368 return { 

369 'isolated_deployment': True, 

370 } 

371 

372 # Fetch RSS content from cache if available. Cached content is always sanitized before 

373 # it is written (see below), so no sanitization is needed on read. 

374 if feed_content := cache.get(self.cache_key): 

375 return { 

376 'feed': feedparser.FeedParserDict(feed_content), 

377 } 

378 

379 # Fetch feed content from remote server 

380 try: 

381 response = requests.get( 

382 url=self.config['feed_url'], 

383 headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, 

384 proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}), 

385 timeout=self.config.get('request_timeout', 3), 

386 ) 

387 response.raise_for_status() 

388 except requests.exceptions.RequestException as e: 

389 return { 

390 'error': e, 

391 } 

392 

393 # Parse feed content 

394 feed = feedparser.parse(response.content) 

395 if not feed.bozo: 

396 # Cap number of entries 

397 max_entries = self.config.get('max_entries') 

398 feed['entries'] = feed['entries'][:max_entries] 

399 # Sanitize feed-controlled content before caching/rendering 

400 self.sanitize_entries(feed['entries']) 

401 # Cache the feed content 

402 cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout')) 

403 

404 return { 

405 'feed': feed, 

406 } 

407 

408 @staticmethod 

409 def sanitize_entries(entries): 

410 """ 

411 Sanitize feed-controlled entry content in place. The feed URL is untrusted external 

412 content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry 

413 links and sanitize entry summaries as defense-in-depth. 

414 """ 

415 allowed_schemes = get_config().ALLOWED_URL_SCHEMES 

416 for entry in entries: 

417 # Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.). 

418 # This is the load-bearing control: the template renders entry.link into an href. 

419 if link := entry.get('link'): 

420 result = urlparse(link) 

421 if result.scheme and result.scheme.lower() not in allowed_schemes: 

422 entry['link'] = '' 

423 # Sanitize the summary HTML as defense-in-depth. The template renders entry.summary 

424 # with auto-escaping (not |safe), so this is not currently load-bearing; it guards 

425 # against a future change that renders the summary as markup. 

426 if summary := entry.get('summary'): 

427 entry['summary'] = clean_html(summary, allowed_schemes) 

428 

429 

430@register_widget 

431class BookmarksWidget(DashboardWidget): 

432 default_title = _('Bookmarks') 

433 default_config = { 

434 'order_by': BookmarkOrderingChoices.ORDERING_NEWEST, 

435 } 

436 description = _('Show your personal bookmarks') 

437 template_name = 'extras/dashboard/widgets/bookmarks.html' 

438 

439 class ConfigForm(WidgetConfigForm): 

440 object_types = forms.MultipleChoiceField( 

441 choices=get_bookmarks_object_type_choices, 

442 required=False 

443 ) 

444 order_by = forms.ChoiceField( 

445 choices=BookmarkOrderingChoices 

446 ) 

447 max_items = forms.IntegerField( 

448 min_value=1, 

449 required=False 

450 ) 

451 

452 def render(self, request): 

453 from extras.models import Bookmark 

454 

455 if request.user.is_anonymous: 

456 bookmarks = list() 

457 else: 

458 bookmarks = Bookmark.objects.filter(user=request.user) 

459 if object_types := self.config.get('object_types'): 

460 models = get_models_from_content_types(object_types) 

461 content_types = ObjectType.objects.get_for_models(*models).values() 

462 bookmarks = bookmarks.filter(object_type__in=content_types) 

463 if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ: 

464 bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower()) 

465 elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA: 

466 bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True) 

467 else: 

468 bookmarks = bookmarks.order_by(self.config['order_by']) 

469 if max_items := self.config.get('max_items'): 

470 bookmarks = bookmarks[:max_items] 

471 

472 return render_to_string(self.template_name, { 

473 'bookmarks': bookmarks, 

474 })