We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
New dashboard widgets have insufficient header contrast in dark mode
### NetBox version

v3.5.0-dev

### Python version

3.8

### Steps to Reproduce

1. Navigate to the dashboard screen with the new widgets.

### Expected Behavior

The text and icon colors within the card header should maintain at least the same contrast ratio between light and dark modes. You can see this done within the search and log in buttons in the screenshots below.

### Observed Behavior

Currently, the colors used for the text and icons for card headers in dark mode do not provide much contrast against the background, making them very difficult to see. Screenshots below for reference.

![beta-demo netbox dev_light](https://user-images.githubusercontent.com/24281183/231177958-95e85747-d315-48d9-9738-1c5b4acab55e.png)
![beta-demo netbox dev_dark](https://user-images.githubusercontent.com/24281183/231177957-b4edc302-60f1-4842-b931-8bea66a1a37c.png)

--- END ISSUE ---

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

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
--- a/netbox/extras/dashboard/widgets.py
+++ b/netbox/extras/dashboard/widgets.py
@@ -16,6 +16,7 @@
 
 from extras.choices import BookmarkOrderingChoices
 from extras.utils import FeatureQuery
+from utilities.choices import ButtonColorChoices
 from utilities.forms import BootstrapMixin
 from utilities.permissions import get_permission_for_model
 from utilities.templatetags.builtins.filters import render_markdown
@@ -115,6 +116,22 @@
     def name(self):
         return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
 
+    @property
+    def fg_color(self):
+        """
+        Return the appropriate foreground (text) color for the widget's color.
+        """
+        if self.color in (
+            ButtonColorChoices.CYAN,
+            ButtonColorChoices.GRAY,
+            ButtonColorChoices.GREY,
+            ButtonColorChoices.TEAL,
+            ButtonColorChoices.WHITE,
+            ButtonColorChoices.YELLOW,
+        ):
+            return ButtonColorChoices.BLACK
+        return ButtonColorChoices.WHITE
+
     @property
     def form_data(self):
         return {

{"golden_diff": "diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py\n--- a/netbox/extras/dashboard/widgets.py\n+++ b/netbox/extras/dashboard/widgets.py\n@@ -16,6 +16,7 @@\n \n from extras.choices import BookmarkOrderingChoices\n from extras.utils import FeatureQuery\n+from utilities.choices import ButtonColorChoices\n from utilities.forms import BootstrapMixin\n from utilities.permissions import get_permission_for_model\n from utilities.templatetags.builtins.filters import render_markdown\n@@ -115,6 +116,22 @@\n     def name(self):\n         return f'{self.__class__.__module__.split(\".\")[0]}.{self.__class__.__name__}'\n \n+    @property\n+    def fg_color(self):\n+        \"\"\"\n+        Return the appropriate foreground (text) color for the widget's color.\n+        \"\"\"\n+        if self.color in (\n+            ButtonColorChoices.CYAN,\n+            ButtonColorChoices.GRAY,\n+            ButtonColorChoices.GREY,\n+            ButtonColorChoices.TEAL,\n+            ButtonColorChoices.WHITE,\n+            ButtonColorChoices.YELLOW,\n+        ):\n+            return ButtonColorChoices.BLACK\n+        return ButtonColorChoices.WHITE\n+\n     @property\n     def form_data(self):\n         return {\n", "issue": "New dashboard widgets have insufficient header contrast in dark mode\n### NetBox version\n\nv3.5.0-dev\n\n### Python version\n\n3.8\n\n### Steps to Reproduce\n\n1. Navigate to the dashboard screen with the new widgets.\n\n### Expected Behavior\n\nThe text and icon colors within the card header should maintain at least the same contrast ratio between light and dark modes. You can see this done within the search and log in buttons in the screenshots below.\n\n### Observed Behavior\n\nCurrently, the colors used for the text and icons for card headers in dark mode do not provide much contrast against the background, making them very difficult to see. Screenshots below for reference.\r\n\r\n![beta-demo netbox dev_light](https://user-images.githubusercontent.com/24281183/231177958-95e85747-d315-48d9-9738-1c5b4acab55e.png)\r\n![beta-demo netbox dev_dark](https://user-images.githubusercontent.com/24281183/231177957-b4edc302-60f1-4842-b931-8bea66a1a37c.png)\n", "before_files": [{"content": "import uuid\nfrom functools import cached_property\nfrom hashlib import sha256\nfrom urllib.parse import urlencode\n\nimport feedparser\nimport requests\nfrom django import forms\nfrom django.conf import settings\nfrom django.contrib.contenttypes.models import ContentType\nfrom django.core.cache import cache\nfrom django.db.models import Q\nfrom django.template.loader import render_to_string\nfrom django.urls import NoReverseMatch, resolve, reverse\nfrom django.utils.translation import gettext as _\n\nfrom extras.choices import BookmarkOrderingChoices\nfrom extras.utils import FeatureQuery\nfrom utilities.forms import BootstrapMixin\nfrom utilities.permissions import get_permission_for_model\nfrom utilities.templatetags.builtins.filters import render_markdown\nfrom utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname\nfrom .utils import register_widget\n\n__all__ = (\n    'BookmarksWidget',\n    'DashboardWidget',\n    'NoteWidget',\n    'ObjectCountsWidget',\n    'ObjectListWidget',\n    'RSSFeedWidget',\n    'WidgetConfigForm',\n)\n\n\ndef get_content_type_labels():\n    return [\n        (content_type_identifier(ct), content_type_name(ct))\n        for ct in ContentType.objects.filter(\n            FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |\n            Q(app_label='extras', model='configcontext')\n        ).order_by('app_label', 'model')\n    ]\n\n\ndef get_models_from_content_types(content_types):\n    \"\"\"\n    Return a list of models corresponding to the given content types, identified by natural key.\n    \"\"\"\n    models = []\n    for content_type_id in content_types:\n        app_label, model_name = content_type_id.split('.')\n        content_type = ContentType.objects.get_by_natural_key(app_label, model_name)\n        models.append(content_type.model_class())\n    return models\n\n\nclass WidgetConfigForm(BootstrapMixin, forms.Form):\n    pass\n\n\nclass DashboardWidget:\n    \"\"\"\n    Base class for custom dashboard widgets.\n\n    Attributes:\n        description: A brief, user-friendly description of the widget's function\n        default_title: The string to show for the widget's title when none has been specified.\n        default_config: Default configuration parameters, as a dictionary mapping\n        width: The widget's default width (1 to 12)\n        height: The widget's default height; the number of rows it consumes\n    \"\"\"\n    description = None\n    default_title = None\n    default_config = {}\n    width = 4\n    height = 3\n\n    class ConfigForm(WidgetConfigForm):\n        \"\"\"\n        The widget's configuration form.\n        \"\"\"\n        pass\n\n    def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):\n        self.id = id or str(uuid.uuid4())\n        self.config = config or self.default_config\n        self.title = title or self.default_title\n        self.color = color\n        if width:\n            self.width = width\n        if height:\n            self.height = height\n        self.x, self.y = x, y\n\n    def __str__(self):\n        return self.title or self.__class__.__name__\n\n    def set_layout(self, grid_item):\n        self.width = grid_item.get('w', 1)\n        self.height = grid_item.get('h', 1)\n        self.x = grid_item.get('x')\n        self.y = grid_item.get('y')\n\n    def render(self, request):\n        \"\"\"\n        This method is called to render the widget's content.\n\n        Params:\n            request: The current request\n        \"\"\"\n        raise NotImplementedError(f\"{self.__class__} must define a render() method.\")\n\n    @property\n    def name(self):\n        return f'{self.__class__.__module__.split(\".\")[0]}.{self.__class__.__name__}'\n\n    @property\n    def form_data(self):\n        return {\n            'title': self.title,\n            'color': self.color,\n            'config': self.config,\n        }\n\n\n@register_widget\nclass NoteWidget(DashboardWidget):\n    default_title = _('Note')\n    description = _('Display some arbitrary custom content. Markdown is supported.')\n\n    class ConfigForm(WidgetConfigForm):\n        content = forms.CharField(\n            widget=forms.Textarea()\n        )\n\n    def render(self, request):\n        return render_markdown(self.config.get('content'))\n\n\n@register_widget\nclass ObjectCountsWidget(DashboardWidget):\n    default_title = _('Object Counts')\n    description = _('Display a set of NetBox models and the number of objects created for each type.')\n    template_name = 'extras/dashboard/widgets/objectcounts.html'\n\n    class ConfigForm(WidgetConfigForm):\n        models = forms.MultipleChoiceField(\n            choices=get_content_type_labels\n        )\n        filters = forms.JSONField(\n            required=False,\n            label='Object filters',\n            help_text=_(\"Filters to apply when counting the number of objects\")\n        )\n\n        def clean_filters(self):\n            if data := self.cleaned_data['filters']:\n                try:\n                    dict(data)\n                except TypeError:\n                    raise forms.ValidationError(\"Invalid format. Object filters must be passed as a dictionary.\")\n            return data\n\n    def render(self, request):\n        counts = []\n        for model in get_models_from_content_types(self.config['models']):\n            permission = get_permission_for_model(model, 'view')\n            if request.user.has_perm(permission):\n                url = reverse(get_viewname(model, 'list'))\n                qs = model.objects.restrict(request.user, 'view')\n                # Apply any specified filters\n                if filters := self.config.get('filters'):\n                    params = dict_to_querydict(filters)\n                    filterset = getattr(resolve(url).func.view_class, 'filterset', None)\n                    qs = filterset(params, qs).qs\n                    url = f'{url}?{params.urlencode()}'\n                object_count = qs.count\n                counts.append((model, object_count, url))\n            else:\n                counts.append((model, None, None))\n\n        return render_to_string(self.template_name, {\n            'counts': counts,\n        })\n\n\n@register_widget\nclass ObjectListWidget(DashboardWidget):\n    default_title = _('Object List')\n    description = _('Display an arbitrary list of objects.')\n    template_name = 'extras/dashboard/widgets/objectlist.html'\n    width = 12\n    height = 4\n\n    class ConfigForm(WidgetConfigForm):\n        model = forms.ChoiceField(\n            choices=get_content_type_labels\n        )\n        page_size = forms.IntegerField(\n            required=False,\n            min_value=1,\n            max_value=100,\n            help_text=_('The default number of objects to display')\n        )\n        url_params = forms.JSONField(\n            required=False,\n            label='URL parameters'\n        )\n\n        def clean_url_params(self):\n            if data := self.cleaned_data['url_params']:\n                try:\n                    urlencode(data)\n                except (TypeError, ValueError):\n                    raise forms.ValidationError(\"Invalid format. URL parameters must be passed as a dictionary.\")\n            return data\n\n    def render(self, request):\n        app_label, model_name = self.config['model'].split('.')\n        model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()\n        viewname = get_viewname(model, action='list')\n\n        # Evaluate user's permission. Note that this controls only whether the HTMX element is\n        # embedded on the page: The view itself will also evaluate permissions separately.\n        permission = get_permission_for_model(model, 'view')\n        has_permission = request.user.has_perm(permission)\n\n        try:\n            htmx_url = reverse(viewname)\n        except NoReverseMatch:\n            htmx_url = None\n        parameters = self.config.get('url_params') or {}\n        if page_size := self.config.get('page_size'):\n            parameters['per_page'] = page_size\n\n        if parameters:\n            try:\n                htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'\n            except ValueError:\n                pass\n        return render_to_string(self.template_name, {\n            'viewname': viewname,\n            'has_permission': has_permission,\n            'htmx_url': htmx_url,\n        })\n\n\n@register_widget\nclass RSSFeedWidget(DashboardWidget):\n    default_title = _('RSS Feed')\n    default_config = {\n        'max_entries': 10,\n        'cache_timeout': 3600,  # seconds\n    }\n    description = _('Embed an RSS feed from an external website.')\n    template_name = 'extras/dashboard/widgets/rssfeed.html'\n    width = 6\n    height = 4\n\n    class ConfigForm(WidgetConfigForm):\n        feed_url = forms.URLField(\n            label=_('Feed URL')\n        )\n        max_entries = forms.IntegerField(\n            min_value=1,\n            max_value=1000,\n            help_text=_('The maximum number of objects to display')\n        )\n        cache_timeout = forms.IntegerField(\n            min_value=600,  # 10 minutes\n            max_value=86400,  # 24 hours\n            help_text=_('How long to stored the cached content (in seconds)')\n        )\n\n    def render(self, request):\n        return render_to_string(self.template_name, {\n            'url': self.config['feed_url'],\n            **self.get_feed()\n        })\n\n    @cached_property\n    def cache_key(self):\n        url = self.config['feed_url']\n        url_checksum = sha256(url.encode('utf-8')).hexdigest()\n        return f'dashboard_rss_{url_checksum}'\n\n    def get_feed(self):\n        # Fetch RSS content from cache if available\n        if feed_content := cache.get(self.cache_key):\n            return {\n                'feed': feedparser.FeedParserDict(feed_content),\n            }\n\n        # Fetch feed content from remote server\n        try:\n            response = requests.get(\n                url=self.config['feed_url'],\n                headers={'User-Agent': f'NetBox/{settings.VERSION}'},\n                proxies=settings.HTTP_PROXIES,\n                timeout=3\n            )\n            response.raise_for_status()\n        except requests.exceptions.RequestException as e:\n            return {\n                'error': e,\n            }\n\n        # Parse feed content\n        feed = feedparser.parse(response.content)\n        if not feed.bozo:\n            # Cap number of entries\n            max_entries = self.config.get('max_entries')\n            feed['entries'] = feed['entries'][:max_entries]\n            # Cache the feed content\n            cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))\n\n        return {\n            'feed': feed,\n        }\n\n\n@register_widget\nclass BookmarksWidget(DashboardWidget):\n    default_title = _('Bookmarks')\n    default_config = {\n        'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,\n    }\n    description = _('Show your personal bookmarks')\n    template_name = 'extras/dashboard/widgets/bookmarks.html'\n\n    class ConfigForm(WidgetConfigForm):\n        object_types = forms.MultipleChoiceField(\n            # TODO: Restrict the choices by FeatureQuery('bookmarks')\n            choices=get_content_type_labels,\n            required=False\n        )\n        order_by = forms.ChoiceField(\n            choices=BookmarkOrderingChoices\n        )\n        max_items = forms.IntegerField(\n            min_value=1,\n            required=False\n        )\n\n    def render(self, request):\n        from extras.models import Bookmark\n\n        if request.user.is_anonymous:\n            bookmarks = list()\n        else:\n            bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])\n            if object_types := self.config.get('object_types'):\n                models = get_models_from_content_types(object_types)\n                conent_types = ContentType.objects.get_for_models(*models).values()\n                bookmarks = bookmarks.filter(object_type__in=conent_types)\n            if max_items := self.config.get('max_items'):\n                bookmarks = bookmarks[:max_items]\n\n        return render_to_string(self.template_name, {\n            'bookmarks': bookmarks,\n        })\n", "path": "netbox/extras/dashboard/widgets.py"}], "after_files": [{"content": "import uuid\nfrom functools import cached_property\nfrom hashlib import sha256\nfrom urllib.parse import urlencode\n\nimport feedparser\nimport requests\nfrom django import forms\nfrom django.conf import settings\nfrom django.contrib.contenttypes.models import ContentType\nfrom django.core.cache import cache\nfrom django.db.models import Q\nfrom django.template.loader import render_to_string\nfrom django.urls import NoReverseMatch, resolve, reverse\nfrom django.utils.translation import gettext as _\n\nfrom extras.choices import BookmarkOrderingChoices\nfrom extras.utils import FeatureQuery\nfrom utilities.choices import ButtonColorChoices\nfrom utilities.forms import BootstrapMixin\nfrom utilities.permissions import get_permission_for_model\nfrom utilities.templatetags.builtins.filters import render_markdown\nfrom utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname\nfrom .utils import register_widget\n\n__all__ = (\n    'BookmarksWidget',\n    'DashboardWidget',\n    'NoteWidget',\n    'ObjectCountsWidget',\n    'ObjectListWidget',\n    'RSSFeedWidget',\n    'WidgetConfigForm',\n)\n\n\ndef get_content_type_labels():\n    return [\n        (content_type_identifier(ct), content_type_name(ct))\n        for ct in ContentType.objects.filter(\n            FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |\n            Q(app_label='extras', model='configcontext')\n        ).order_by('app_label', 'model')\n    ]\n\n\ndef get_models_from_content_types(content_types):\n    \"\"\"\n    Return a list of models corresponding to the given content types, identified by natural key.\n    \"\"\"\n    models = []\n    for content_type_id in content_types:\n        app_label, model_name = content_type_id.split('.')\n        content_type = ContentType.objects.get_by_natural_key(app_label, model_name)\n        models.append(content_type.model_class())\n    return models\n\n\nclass WidgetConfigForm(BootstrapMixin, forms.Form):\n    pass\n\n\nclass DashboardWidget:\n    \"\"\"\n    Base class for custom dashboard widgets.\n\n    Attributes:\n        description: A brief, user-friendly description of the widget's function\n        default_title: The string to show for the widget's title when none has been specified.\n        default_config: Default configuration parameters, as a dictionary mapping\n        width: The widget's default width (1 to 12)\n        height: The widget's default height; the number of rows it consumes\n    \"\"\"\n    description = None\n    default_title = None\n    default_config = {}\n    width = 4\n    height = 3\n\n    class ConfigForm(WidgetConfigForm):\n        \"\"\"\n        The widget's configuration form.\n        \"\"\"\n        pass\n\n    def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):\n        self.id = id or str(uuid.uuid4())\n        self.config = config or self.default_config\n        self.title = title or self.default_title\n        self.color = color\n        if width:\n            self.width = width\n        if height:\n            self.height = height\n        self.x, self.y = x, y\n\n    def __str__(self):\n        return self.title or self.__class__.__name__\n\n    def set_layout(self, grid_item):\n        self.width = grid_item.get('w', 1)\n        self.height = grid_item.get('h', 1)\n        self.x = grid_item.get('x')\n        self.y = grid_item.get('y')\n\n    def render(self, request):\n        \"\"\"\n        This method is called to render the widget's content.\n\n        Params:\n            request: The current request\n        \"\"\"\n        raise NotImplementedError(f\"{self.__class__} must define a render() method.\")\n\n    @property\n    def name(self):\n        return f'{self.__class__.__module__.split(\".\")[0]}.{self.__class__.__name__}'\n\n    @property\n    def fg_color(self):\n        \"\"\"\n        Return the appropriate foreground (text) color for the widget's color.\n        \"\"\"\n        if self.color in (\n            ButtonColorChoices.CYAN,\n            ButtonColorChoices.GRAY,\n            ButtonColorChoices.GREY,\n            ButtonColorChoices.TEAL,\n            ButtonColorChoices.WHITE,\n            ButtonColorChoices.YELLOW,\n        ):\n            return ButtonColorChoices.BLACK\n        return ButtonColorChoices.WHITE\n\n    @property\n    def form_data(self):\n        return {\n            'title': self.title,\n            'color': self.color,\n            'config': self.config,\n        }\n\n\n@register_widget\nclass NoteWidget(DashboardWidget):\n    default_title = _('Note')\n    description = _('Display some arbitrary custom content. Markdown is supported.')\n\n    class ConfigForm(WidgetConfigForm):\n        content = forms.CharField(\n            widget=forms.Textarea()\n        )\n\n    def render(self, request):\n        return render_markdown(self.config.get('content'))\n\n\n@register_widget\nclass ObjectCountsWidget(DashboardWidget):\n    default_title = _('Object Counts')\n    description = _('Display a set of NetBox models and the number of objects created for each type.')\n    template_name = 'extras/dashboard/widgets/objectcounts.html'\n\n    class ConfigForm(WidgetConfigForm):\n        models = forms.MultipleChoiceField(\n            choices=get_content_type_labels\n        )\n        filters = forms.JSONField(\n            required=False,\n            label='Object filters',\n            help_text=_(\"Filters to apply when counting the number of objects\")\n        )\n\n        def clean_filters(self):\n            if data := self.cleaned_data['filters']:\n                try:\n                    dict(data)\n                except TypeError:\n                    raise forms.ValidationError(\"Invalid format. Object filters must be passed as a dictionary.\")\n            return data\n\n    def render(self, request):\n        counts = []\n        for model in get_models_from_content_types(self.config['models']):\n            permission = get_permission_for_model(model, 'view')\n            if request.user.has_perm(permission):\n                url = reverse(get_viewname(model, 'list'))\n                qs = model.objects.restrict(request.user, 'view')\n                # Apply any specified filters\n                if filters := self.config.get('filters'):\n                    params = dict_to_querydict(filters)\n                    filterset = getattr(resolve(url).func.view_class, 'filterset', None)\n                    qs = filterset(params, qs).qs\n                    url = f'{url}?{params.urlencode()}'\n                object_count = qs.count\n                counts.append((model, object_count, url))\n            else:\n                counts.append((model, None, None))\n\n        return render_to_string(self.template_name, {\n            'counts': counts,\n        })\n\n\n@register_widget\nclass ObjectListWidget(DashboardWidget):\n    default_title = _('Object List')\n    description = _('Display an arbitrary list of objects.')\n    template_name = 'extras/dashboard/widgets/objectlist.html'\n    width = 12\n    height = 4\n\n    class ConfigForm(WidgetConfigForm):\n        model = forms.ChoiceField(\n            choices=get_content_type_labels\n        )\n        page_size = forms.IntegerField(\n            required=False,\n            min_value=1,\n            max_value=100,\n            help_text=_('The default number of objects to display')\n        )\n        url_params = forms.JSONField(\n            required=False,\n            label='URL parameters'\n        )\n\n        def clean_url_params(self):\n            if data := self.cleaned_data['url_params']:\n                try:\n                    urlencode(data)\n                except (TypeError, ValueError):\n                    raise forms.ValidationError(\"Invalid format. URL parameters must be passed as a dictionary.\")\n            return data\n\n    def render(self, request):\n        app_label, model_name = self.config['model'].split('.')\n        model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()\n        viewname = get_viewname(model, action='list')\n\n        # Evaluate user's permission. Note that this controls only whether the HTMX element is\n        # embedded on the page: The view itself will also evaluate permissions separately.\n        permission = get_permission_for_model(model, 'view')\n        has_permission = request.user.has_perm(permission)\n\n        try:\n            htmx_url = reverse(viewname)\n        except NoReverseMatch:\n            htmx_url = None\n        parameters = self.config.get('url_params') or {}\n        if page_size := self.config.get('page_size'):\n            parameters['per_page'] = page_size\n\n        if parameters:\n            try:\n                htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'\n            except ValueError:\n                pass\n        return render_to_string(self.template_name, {\n            'viewname': viewname,\n            'has_permission': has_permission,\n            'htmx_url': htmx_url,\n        })\n\n\n@register_widget\nclass RSSFeedWidget(DashboardWidget):\n    default_title = _('RSS Feed')\n    default_config = {\n        'max_entries': 10,\n        'cache_timeout': 3600,  # seconds\n    }\n    description = _('Embed an RSS feed from an external website.')\n    template_name = 'extras/dashboard/widgets/rssfeed.html'\n    width = 6\n    height = 4\n\n    class ConfigForm(WidgetConfigForm):\n        feed_url = forms.URLField(\n            label=_('Feed URL')\n        )\n        max_entries = forms.IntegerField(\n            min_value=1,\n            max_value=1000,\n            help_text=_('The maximum number of objects to display')\n        )\n        cache_timeout = forms.IntegerField(\n            min_value=600,  # 10 minutes\n            max_value=86400,  # 24 hours\n            help_text=_('How long to stored the cached content (in seconds)')\n        )\n\n    def render(self, request):\n        return render_to_string(self.template_name, {\n            'url': self.config['feed_url'],\n            **self.get_feed()\n        })\n\n    @cached_property\n    def cache_key(self):\n        url = self.config['feed_url']\n        url_checksum = sha256(url.encode('utf-8')).hexdigest()\n        return f'dashboard_rss_{url_checksum}'\n\n    def get_feed(self):\n        # Fetch RSS content from cache if available\n        if feed_content := cache.get(self.cache_key):\n            return {\n                'feed': feedparser.FeedParserDict(feed_content),\n            }\n\n        # Fetch feed content from remote server\n        try:\n            response = requests.get(\n                url=self.config['feed_url'],\n                headers={'User-Agent': f'NetBox/{settings.VERSION}'},\n                proxies=settings.HTTP_PROXIES,\n                timeout=3\n            )\n            response.raise_for_status()\n        except requests.exceptions.RequestException as e:\n            return {\n                'error': e,\n            }\n\n        # Parse feed content\n        feed = feedparser.parse(response.content)\n        if not feed.bozo:\n            # Cap number of entries\n            max_entries = self.config.get('max_entries')\n            feed['entries'] = feed['entries'][:max_entries]\n            # Cache the feed content\n            cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))\n\n        return {\n            'feed': feed,\n        }\n\n\n@register_widget\nclass BookmarksWidget(DashboardWidget):\n    default_title = _('Bookmarks')\n    default_config = {\n        'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,\n    }\n    description = _('Show your personal bookmarks')\n    template_name = 'extras/dashboard/widgets/bookmarks.html'\n\n    class ConfigForm(WidgetConfigForm):\n        object_types = forms.MultipleChoiceField(\n            # TODO: Restrict the choices by FeatureQuery('bookmarks')\n            choices=get_content_type_labels,\n            required=False\n        )\n        order_by = forms.ChoiceField(\n            choices=BookmarkOrderingChoices\n        )\n        max_items = forms.IntegerField(\n            min_value=1,\n            required=False\n        )\n\n    def render(self, request):\n        from extras.models import Bookmark\n\n        if request.user.is_anonymous:\n            bookmarks = list()\n        else:\n            bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])\n            if object_types := self.config.get('object_types'):\n                models = get_models_from_content_types(object_types)\n                conent_types = ContentType.objects.get_for_models(*models).values()\n                bookmarks = bookmarks.filter(object_type__in=conent_types)\n            if max_items := self.config.get('max_items'):\n                bookmarks = bookmarks[:max_items]\n\n        return render_to_string(self.template_name, {\n            'bookmarks': bookmarks,\n        })\n", "path": "netbox/extras/dashboard/widgets.py"}]}