widgets.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import logging
  2. import uuid
  3. from functools import cached_property
  4. from hashlib import sha256
  5. from urllib.parse import urlencode
  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.template.loader import render_to_string
  12. from django.urls import NoReverseMatch, resolve, reverse
  13. from django.utils.translation import gettext as _
  14. from core.models import ObjectType
  15. from extras.choices import BookmarkOrderingChoices
  16. from netbox.choices import ButtonColorChoices
  17. from utilities.object_types import object_type_identifier, object_type_name
  18. from utilities.permissions import get_permission_for_model
  19. from utilities.querydict import dict_to_querydict
  20. from utilities.templatetags.builtins.filters import render_markdown
  21. from utilities.views import get_viewname
  22. from .utils import register_widget
  23. __all__ = (
  24. 'BookmarksWidget',
  25. 'DashboardWidget',
  26. 'NoteWidget',
  27. 'ObjectCountsWidget',
  28. 'ObjectListWidget',
  29. 'RSSFeedWidget',
  30. 'WidgetConfigForm',
  31. )
  32. logger = logging.getLogger('netbox.data_backends')
  33. def get_object_type_choices():
  34. return [
  35. (object_type_identifier(ot), object_type_name(ot))
  36. for ot in ObjectType.objects.public().order_by('app_label', 'model')
  37. ]
  38. def get_bookmarks_object_type_choices():
  39. return [
  40. (object_type_identifier(ot), object_type_name(ot))
  41. for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
  42. ]
  43. def get_models_from_content_types(content_types):
  44. """
  45. Return a list of models corresponding to the given content types, identified by natural key.
  46. """
  47. models = []
  48. for content_type_id in content_types:
  49. app_label, model_name = content_type_id.split('.')
  50. try:
  51. content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
  52. if content_type.model_class():
  53. models.append(content_type.model_class())
  54. else:
  55. logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
  56. except ObjectType.DoesNotExist:
  57. logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}")
  58. return models
  59. class WidgetConfigForm(forms.Form):
  60. pass
  61. class DashboardWidget:
  62. """
  63. Base class for custom dashboard widgets.
  64. Attributes:
  65. description: A brief, user-friendly description of the widget's function
  66. default_title: The string to show for the widget's title when none has been specified.
  67. default_config: Default configuration parameters, as a dictionary mapping
  68. width: The widget's default width (1 to 12)
  69. height: The widget's default height; the number of rows it consumes
  70. """
  71. description = None
  72. default_title = None
  73. default_config = {}
  74. width = 4
  75. height = 3
  76. class ConfigForm(WidgetConfigForm):
  77. """
  78. The widget's configuration form.
  79. """
  80. pass
  81. def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
  82. self.id = id or str(uuid.uuid4())
  83. self.config = config or self.default_config
  84. self.title = title or self.default_title
  85. self.color = color
  86. if width:
  87. self.width = width
  88. if height:
  89. self.height = height
  90. self.x, self.y = x, y
  91. def __str__(self):
  92. return self.title or self.__class__.__name__
  93. def set_layout(self, grid_item):
  94. self.width = grid_item.get('w', 1)
  95. self.height = grid_item.get('h', 1)
  96. self.x = grid_item.get('x')
  97. self.y = grid_item.get('y')
  98. def render(self, request):
  99. """
  100. This method is called to render the widget's content.
  101. Params:
  102. request: The current request
  103. """
  104. raise NotImplementedError(_("{class_name} must define a render() method.").format(
  105. class_name=self.__class__
  106. ))
  107. @property
  108. def name(self):
  109. return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
  110. @property
  111. def fg_color(self):
  112. """
  113. Return the appropriate foreground (text) color for the widget's color.
  114. """
  115. if self.color in (
  116. ButtonColorChoices.CYAN,
  117. ButtonColorChoices.GRAY,
  118. ButtonColorChoices.GREY,
  119. ButtonColorChoices.TEAL,
  120. ButtonColorChoices.WHITE,
  121. ButtonColorChoices.YELLOW,
  122. ):
  123. return ButtonColorChoices.BLACK
  124. return ButtonColorChoices.WHITE
  125. @property
  126. def form_data(self):
  127. return {
  128. 'title': self.title,
  129. 'color': self.color,
  130. 'config': self.config,
  131. }
  132. @register_widget
  133. class NoteWidget(DashboardWidget):
  134. default_title = _('Note')
  135. description = _('Display some arbitrary custom content. Markdown is supported.')
  136. class ConfigForm(WidgetConfigForm):
  137. content = forms.CharField(
  138. widget=forms.Textarea()
  139. )
  140. def render(self, request):
  141. return render_markdown(self.config.get('content'))
  142. @register_widget
  143. class ObjectCountsWidget(DashboardWidget):
  144. default_title = _('Object Counts')
  145. description = _('Display a set of NetBox models and the number of objects created for each type.')
  146. template_name = 'extras/dashboard/widgets/objectcounts.html'
  147. class ConfigForm(WidgetConfigForm):
  148. models = forms.MultipleChoiceField(
  149. choices=get_object_type_choices
  150. )
  151. filters = forms.JSONField(
  152. required=False,
  153. label='Object filters',
  154. help_text=_("Filters to apply when counting the number of objects")
  155. )
  156. def clean_filters(self):
  157. if data := self.cleaned_data['filters']:
  158. try:
  159. dict(data)
  160. except TypeError:
  161. raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
  162. return data
  163. def render(self, request):
  164. counts = []
  165. for model in get_models_from_content_types(self.config['models']):
  166. permission = get_permission_for_model(model, 'view')
  167. if request.user.has_perm(permission):
  168. url = reverse(get_viewname(model, 'list'))
  169. qs = model.objects.restrict(request.user, 'view')
  170. # Apply any specified filters
  171. if filters := self.config.get('filters'):
  172. params = dict_to_querydict(filters)
  173. filterset = getattr(resolve(url).func.view_class, 'filterset', None)
  174. qs = filterset(params, qs).qs
  175. url = f'{url}?{params.urlencode()}'
  176. object_count = qs.count
  177. counts.append((model, object_count, url))
  178. else:
  179. counts.append((model, None, None))
  180. return render_to_string(self.template_name, {
  181. 'counts': counts,
  182. })
  183. @register_widget
  184. class ObjectListWidget(DashboardWidget):
  185. default_title = _('Object List')
  186. description = _('Display an arbitrary list of objects.')
  187. template_name = 'extras/dashboard/widgets/objectlist.html'
  188. width = 12
  189. height = 4
  190. class ConfigForm(WidgetConfigForm):
  191. model = forms.ChoiceField(
  192. choices=get_object_type_choices
  193. )
  194. page_size = forms.IntegerField(
  195. required=False,
  196. min_value=1,
  197. max_value=100,
  198. help_text=_('The default number of objects to display')
  199. )
  200. url_params = forms.JSONField(
  201. required=False,
  202. label='URL parameters'
  203. )
  204. def clean_url_params(self):
  205. if data := self.cleaned_data['url_params']:
  206. try:
  207. urlencode(data)
  208. except (TypeError, ValueError):
  209. raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
  210. return data
  211. def render(self, request):
  212. app_label, model_name = self.config['model'].split('.')
  213. model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
  214. if not model:
  215. logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
  216. return
  217. viewname = get_viewname(model, action='list')
  218. # Evaluate user's permission. Note that this controls only whether the HTMX element is
  219. # embedded on the page: The view itself will also evaluate permissions separately.
  220. permission = get_permission_for_model(model, 'view')
  221. has_permission = request.user.has_perm(permission)
  222. try:
  223. htmx_url = reverse(viewname)
  224. except NoReverseMatch:
  225. htmx_url = None
  226. parameters = self.config.get('url_params') or {}
  227. if page_size := self.config.get('page_size'):
  228. parameters['per_page'] = page_size
  229. parameters['embedded'] = True
  230. if parameters:
  231. try:
  232. htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
  233. except ValueError:
  234. pass
  235. return render_to_string(self.template_name, {
  236. 'viewname': viewname,
  237. 'has_permission': has_permission,
  238. 'htmx_url': htmx_url,
  239. })
  240. @register_widget
  241. class RSSFeedWidget(DashboardWidget):
  242. default_title = _('RSS Feed')
  243. default_config = {
  244. 'max_entries': 10,
  245. 'cache_timeout': 3600, # seconds
  246. }
  247. description = _('Embed an RSS feed from an external website.')
  248. template_name = 'extras/dashboard/widgets/rssfeed.html'
  249. width = 6
  250. height = 4
  251. class ConfigForm(WidgetConfigForm):
  252. feed_url = forms.URLField(
  253. label=_('Feed URL')
  254. )
  255. max_entries = forms.IntegerField(
  256. min_value=1,
  257. max_value=1000,
  258. help_text=_('The maximum number of objects to display')
  259. )
  260. cache_timeout = forms.IntegerField(
  261. min_value=600, # 10 minutes
  262. max_value=86400, # 24 hours
  263. help_text=_('How long to stored the cached content (in seconds)')
  264. )
  265. def render(self, request):
  266. return render_to_string(self.template_name, {
  267. 'url': self.config['feed_url'],
  268. **self.get_feed()
  269. })
  270. @cached_property
  271. def cache_key(self):
  272. url = self.config['feed_url']
  273. url_checksum = sha256(url.encode('utf-8')).hexdigest()
  274. return f'dashboard_rss_{url_checksum}'
  275. def get_feed(self):
  276. # Fetch RSS content from cache if available
  277. if feed_content := cache.get(self.cache_key):
  278. return {
  279. 'feed': feedparser.FeedParserDict(feed_content),
  280. }
  281. # Fetch feed content from remote server
  282. try:
  283. response = requests.get(
  284. url=self.config['feed_url'],
  285. headers={'User-Agent': f'NetBox/{settings.VERSION}'},
  286. proxies=settings.HTTP_PROXIES,
  287. timeout=3
  288. )
  289. response.raise_for_status()
  290. except requests.exceptions.RequestException as e:
  291. return {
  292. 'error': e,
  293. }
  294. # Parse feed content
  295. feed = feedparser.parse(response.content)
  296. if not feed.bozo:
  297. # Cap number of entries
  298. max_entries = self.config.get('max_entries')
  299. feed['entries'] = feed['entries'][:max_entries]
  300. # Cache the feed content
  301. cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
  302. return {
  303. 'feed': feed,
  304. }
  305. @register_widget
  306. class BookmarksWidget(DashboardWidget):
  307. default_title = _('Bookmarks')
  308. default_config = {
  309. 'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
  310. }
  311. description = _('Show your personal bookmarks')
  312. template_name = 'extras/dashboard/widgets/bookmarks.html'
  313. class ConfigForm(WidgetConfigForm):
  314. object_types = forms.MultipleChoiceField(
  315. choices=get_bookmarks_object_type_choices,
  316. required=False
  317. )
  318. order_by = forms.ChoiceField(
  319. choices=BookmarkOrderingChoices
  320. )
  321. max_items = forms.IntegerField(
  322. min_value=1,
  323. required=False
  324. )
  325. def render(self, request):
  326. from extras.models import Bookmark
  327. if request.user.is_anonymous:
  328. bookmarks = list()
  329. else:
  330. bookmarks = Bookmark.objects.filter(user=request.user)
  331. if object_types := self.config.get('object_types'):
  332. models = get_models_from_content_types(object_types)
  333. content_types = ObjectType.objects.get_for_models(*models).values()
  334. bookmarks = bookmarks.filter(object_type__in=content_types)
  335. if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
  336. bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
  337. elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
  338. bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
  339. else:
  340. bookmarks = bookmarks.order_by(self.config['order_by'])
  341. if max_items := self.config.get('max_items'):
  342. bookmarks = bookmarks[:max_items]
  343. return render_to_string(self.template_name, {
  344. 'bookmarks': bookmarks,
  345. })