widgets.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import logging
  2. import uuid
  3. from functools import cached_property
  4. from hashlib import sha256
  5. from urllib.parse import urlencode, urlparse
  6. import feedparser
  7. import requests
  8. from django import forms
  9. from django.conf import settings
  10. from django.core.cache import cache
  11. from django.db.models import Model
  12. from django.template.loader import render_to_string
  13. from django.urls import NoReverseMatch, resolve
  14. from django.utils.translation import gettext as _
  15. from core.models import ObjectType
  16. from extras.choices import BookmarkOrderingChoices
  17. from netbox.config import get_config
  18. from utilities.choices import Choice
  19. from utilities.html import clean_html
  20. from utilities.object_types import object_type_identifier, object_type_name
  21. from utilities.permissions import get_permission_for_model
  22. from utilities.proxy import resolve_proxies
  23. from utilities.querydict import dict_to_querydict
  24. from utilities.templatetags.builtins.filters import render_markdown
  25. from utilities.views import get_action_url
  26. from .utils import register_widget
  27. __all__ = (
  28. 'BookmarksWidget',
  29. 'DashboardWidget',
  30. 'NoteWidget',
  31. 'ObjectCountsWidget',
  32. 'ObjectListWidget',
  33. 'RSSFeedWidget',
  34. 'WidgetConfigForm',
  35. )
  36. logger = logging.getLogger('netbox.data_backends')
  37. def get_object_type_choices():
  38. return [
  39. Choice(object_type_identifier(ot), object_type_name(ot))
  40. for ot in ObjectType.objects.public().order_by('app_label', 'model')
  41. ]
  42. def object_list_widget_supports_model(model: Model) -> bool:
  43. """Test whether a model is supported by the ObjectListWidget
  44. In theory there could be more than one reason why a model isn't supported by the
  45. ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
  46. for the model. Add more tests if more conditions arise.
  47. """
  48. def can_resolve_model_list_view(model: Model) -> bool:
  49. try:
  50. get_action_url(model, action='list')
  51. return True
  52. except NoReverseMatch:
  53. return False
  54. tests = [
  55. can_resolve_model_list_view,
  56. ]
  57. return all(test(model) for test in tests)
  58. def get_bookmarks_object_type_choices():
  59. return [
  60. Choice(object_type_identifier(ot), object_type_name(ot))
  61. for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
  62. ]
  63. def get_models_from_content_types(content_types):
  64. """
  65. Return a list of models corresponding to the given content types, identified by natural key.
  66. Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
  67. """
  68. models = []
  69. for content_type_id in content_types:
  70. app_label, model_name = content_type_id.lower().split('.')
  71. try:
  72. content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
  73. if content_type.model_class():
  74. models.append(content_type.model_class())
  75. else:
  76. logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
  77. except ObjectType.DoesNotExist:
  78. logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}")
  79. return models
  80. class WidgetConfigForm(forms.Form):
  81. pass
  82. class DashboardWidget:
  83. """
  84. Base class for custom dashboard widgets.
  85. Attributes:
  86. description: A brief, user-friendly description of the widget's function
  87. default_title: The string to show for the widget's title when none has been specified.
  88. default_config: Default configuration parameters, as a dictionary mapping
  89. width: The widget's default width (1 to 12)
  90. height: The widget's default height; the number of rows it consumes
  91. """
  92. description = None
  93. default_title = None
  94. default_config = {}
  95. width = 4
  96. height = 3
  97. class ConfigForm(WidgetConfigForm):
  98. """
  99. The widget's configuration form.
  100. """
  101. pass
  102. def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
  103. self.id = id or str(uuid.uuid4())
  104. self.config = config or self.default_config
  105. self.title = title or self.default_title
  106. self.color = color
  107. if width:
  108. self.width = width
  109. if height:
  110. self.height = height
  111. self.x, self.y = x, y
  112. def __str__(self):
  113. return self.title or self.__class__.__name__
  114. def set_layout(self, grid_item):
  115. self.width = grid_item.get('w', 1)
  116. self.height = grid_item.get('h', 1)
  117. self.x = grid_item.get('x')
  118. self.y = grid_item.get('y')
  119. def render(self, request):
  120. """
  121. This method is called to render the widget's content.
  122. Params:
  123. request: The current request
  124. """
  125. raise NotImplementedError(_("{class_name} must define a render() method.").format(
  126. class_name=self.__class__
  127. ))
  128. @property
  129. def name(self):
  130. return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
  131. @property
  132. def form_data(self):
  133. return {
  134. 'title': self.title,
  135. 'color': self.color,
  136. 'config': self.config,
  137. }
  138. @register_widget
  139. class NoteWidget(DashboardWidget):
  140. default_title = _('Note')
  141. description = _('Display some arbitrary custom content. Markdown is supported.')
  142. class ConfigForm(WidgetConfigForm):
  143. content = forms.CharField(
  144. widget=forms.Textarea()
  145. )
  146. def render(self, request):
  147. return render_markdown(self.config.get('content'))
  148. @register_widget
  149. class ObjectCountsWidget(DashboardWidget):
  150. default_title = _('Object Counts')
  151. description = _('Display a set of NetBox models and the number of objects created for each type.')
  152. template_name = 'extras/dashboard/widgets/objectcounts.html'
  153. class ConfigForm(WidgetConfigForm):
  154. models = forms.MultipleChoiceField(
  155. choices=get_object_type_choices
  156. )
  157. filters = forms.JSONField(
  158. required=False,
  159. label='Object filters',
  160. help_text=_("Filters to apply when counting the number of objects")
  161. )
  162. def clean_filters(self):
  163. if data := self.cleaned_data['filters']:
  164. try:
  165. dict(data)
  166. except TypeError:
  167. raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
  168. return data
  169. def render(self, request):
  170. counts = []
  171. for model in get_models_from_content_types(self.config['models']):
  172. permission = get_permission_for_model(model, 'view')
  173. if request.user.has_perm(permission):
  174. try:
  175. url = get_action_url(model, action='list')
  176. except NoReverseMatch:
  177. url = None
  178. try:
  179. qs = model.objects.restrict(request.user, 'view')
  180. except AttributeError:
  181. qs = model.objects.all()
  182. # Apply any specified filters
  183. if url and (filters := self.config.get('filters')):
  184. params = dict_to_querydict(filters)
  185. filterset = getattr(resolve(url).func.view_class, 'filterset', None)
  186. qs = filterset(params, qs).qs
  187. url = f'{url}?{params.urlencode()}'
  188. object_count = qs.count
  189. counts.append((model, object_count, url))
  190. else:
  191. counts.append((model, None, None))
  192. return render_to_string(self.template_name, {
  193. 'counts': counts,
  194. })
  195. @register_widget
  196. class ObjectListWidget(DashboardWidget):
  197. default_title = _('Object List')
  198. description = _('Display an arbitrary list of objects.')
  199. template_name = 'extras/dashboard/widgets/objectlist.html'
  200. width = 12
  201. height = 4
  202. class ConfigForm(WidgetConfigForm):
  203. model = forms.ChoiceField(
  204. choices=get_object_type_choices
  205. )
  206. page_size = forms.IntegerField(
  207. required=False,
  208. min_value=1,
  209. max_value=100,
  210. help_text=_('The default number of objects to display')
  211. )
  212. url_params = forms.JSONField(
  213. required=False,
  214. label='URL parameters'
  215. )
  216. def clean_url_params(self):
  217. if data := self.cleaned_data['url_params']:
  218. try:
  219. urlencode(data)
  220. except (TypeError, ValueError):
  221. raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
  222. return data
  223. def clean_model(self):
  224. if model_info := self.cleaned_data['model']:
  225. app_label, model_name = model_info.split('.')
  226. model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
  227. if not object_list_widget_supports_model(model):
  228. raise forms.ValidationError(
  229. _(f"Invalid model selection: {self['model'].data} is not supported.")
  230. )
  231. return model_info
  232. def render(self, request):
  233. app_label, model_name = self.config['model'].split('.')
  234. model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
  235. if not model:
  236. logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
  237. return None
  238. # Evaluate user's permission. Note that this controls only whether the HTMX element is
  239. # embedded on the page: The view itself will also evaluate permissions separately.
  240. permission = get_permission_for_model(model, 'view')
  241. has_permission = request.user.has_perm(permission)
  242. try:
  243. htmx_url = get_action_url(model, action='list')
  244. except NoReverseMatch:
  245. htmx_url = None
  246. parameters = self.config.get('url_params') or {}
  247. if page_size := self.config.get('page_size'):
  248. parameters['per_page'] = page_size
  249. parameters['embedded'] = True
  250. if parameters and htmx_url is not None:
  251. try:
  252. htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
  253. except ValueError:
  254. pass
  255. return render_to_string(self.template_name, {
  256. 'model_name': model_name,
  257. 'has_permission': has_permission,
  258. 'htmx_url': htmx_url,
  259. })
  260. @register_widget
  261. class RSSFeedWidget(DashboardWidget):
  262. default_title = _('RSS Feed')
  263. default_config = {
  264. 'max_entries': 10,
  265. 'cache_timeout': 3600, # seconds
  266. 'request_timeout': 3, # seconds
  267. 'requires_internet': True,
  268. }
  269. description = _('Embed an RSS feed from an external website.')
  270. template_name = 'extras/dashboard/widgets/rssfeed.html'
  271. width = 6
  272. height = 4
  273. class ConfigForm(WidgetConfigForm):
  274. feed_url = forms.URLField(
  275. label=_('Feed URL'),
  276. assume_scheme='https'
  277. )
  278. requires_internet = forms.BooleanField(
  279. label=_('Requires external connection'),
  280. required=False,
  281. )
  282. max_entries = forms.IntegerField(
  283. min_value=1,
  284. max_value=1000,
  285. help_text=_('The maximum number of objects to display')
  286. )
  287. cache_timeout = forms.IntegerField(
  288. min_value=600, # 10 minutes
  289. max_value=86400, # 24 hours
  290. help_text=_('How long to stored the cached content (in seconds)')
  291. )
  292. request_timeout = forms.IntegerField(
  293. min_value=1,
  294. max_value=60,
  295. required=False,
  296. help_text=_('Timeout value for fetching the feed (in seconds)')
  297. )
  298. def render(self, request):
  299. return render_to_string(self.template_name, {
  300. 'url': self.config['feed_url'],
  301. **self.get_feed()
  302. })
  303. @cached_property
  304. def cache_key(self):
  305. url = self.config['feed_url']
  306. url_checksum = sha256(url.encode('utf-8')).hexdigest()
  307. # The version segment invalidates entries cached by a pre-sanitization release: such
  308. # entries live under the old key and are never read, so they can't be served unsanitized.
  309. return f'dashboard_rss_2_{url_checksum}'
  310. def get_feed(self):
  311. if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
  312. return {
  313. 'isolated_deployment': True,
  314. }
  315. # Fetch RSS content from cache if available. Cached content is always sanitized before
  316. # it is written (see below), so no sanitization is needed on read.
  317. if feed_content := cache.get(self.cache_key):
  318. return {
  319. 'feed': feedparser.FeedParserDict(feed_content),
  320. }
  321. # Fetch feed content from remote server
  322. try:
  323. response = requests.get(
  324. url=self.config['feed_url'],
  325. headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
  326. proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
  327. timeout=self.config.get('request_timeout', 3),
  328. )
  329. response.raise_for_status()
  330. except requests.exceptions.RequestException as e:
  331. return {
  332. 'error': e,
  333. }
  334. # Parse feed content
  335. feed = feedparser.parse(response.content)
  336. if not feed.bozo:
  337. # Cap number of entries
  338. max_entries = self.config.get('max_entries')
  339. feed['entries'] = feed['entries'][:max_entries]
  340. # Sanitize feed-controlled content before caching/rendering
  341. self.sanitize_entries(feed['entries'])
  342. # Cache the feed content
  343. cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
  344. return {
  345. 'feed': feed,
  346. }
  347. @staticmethod
  348. def sanitize_entries(entries):
  349. """
  350. Sanitize feed-controlled entry content in place. The feed URL is untrusted external
  351. content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry
  352. links and sanitize entry summaries as defense-in-depth.
  353. """
  354. allowed_schemes = get_config().ALLOWED_URL_SCHEMES
  355. for entry in entries:
  356. # Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.).
  357. # This is the load-bearing control: the template renders entry.link into an href.
  358. if link := entry.get('link'):
  359. result = urlparse(link)
  360. if result.scheme and result.scheme.lower() not in allowed_schemes:
  361. entry['link'] = ''
  362. # Sanitize the summary HTML as defense-in-depth. The template renders entry.summary
  363. # with auto-escaping (not |safe), so this is not currently load-bearing; it guards
  364. # against a future change that renders the summary as markup.
  365. if summary := entry.get('summary'):
  366. entry['summary'] = clean_html(summary, allowed_schemes)
  367. @register_widget
  368. class BookmarksWidget(DashboardWidget):
  369. default_title = _('Bookmarks')
  370. default_config = {
  371. 'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
  372. }
  373. description = _('Show your personal bookmarks')
  374. template_name = 'extras/dashboard/widgets/bookmarks.html'
  375. class ConfigForm(WidgetConfigForm):
  376. object_types = forms.MultipleChoiceField(
  377. choices=get_bookmarks_object_type_choices,
  378. required=False
  379. )
  380. order_by = forms.ChoiceField(
  381. choices=BookmarkOrderingChoices
  382. )
  383. max_items = forms.IntegerField(
  384. min_value=1,
  385. required=False
  386. )
  387. def render(self, request):
  388. from extras.models import Bookmark
  389. if request.user.is_anonymous:
  390. bookmarks = list()
  391. else:
  392. bookmarks = Bookmark.objects.filter(user=request.user)
  393. if object_types := self.config.get('object_types'):
  394. models = get_models_from_content_types(object_types)
  395. content_types = ObjectType.objects.get_for_models(*models).values()
  396. bookmarks = bookmarks.filter(object_type__in=content_types)
  397. if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
  398. bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
  399. elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
  400. bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
  401. else:
  402. bookmarks = bookmarks.order_by(self.config['order_by'])
  403. if max_items := self.config.get('max_items'):
  404. bookmarks = bookmarks[:max_items]
  405. return render_to_string(self.template_name, {
  406. 'bookmarks': bookmarks,
  407. })