Coverage for extras/views.py: 63%
1048 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from datetime import datetime
3from django.contrib import messages
4from django.contrib.auth.mixins import LoginRequiredMixin
5from django.contrib.contenttypes.models import ContentType
6from django.core.exceptions import ValidationError
7from django.core.paginator import EmptyPage
8from django.db.models import Count, Q
9from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
10from django.shortcuts import get_object_or_404, redirect, render
11from django.urls import reverse
12from django.utils import timezone
13from django.utils.decorators import method_decorator
14from django.utils.http import content_disposition_header
15from django.utils.module_loading import import_string
16from django.utils.translation import gettext_lazy as _
17from django.views.decorators.cache import never_cache
18from django.views.generic import View
20from core.choices import ManagedFileRootPathChoices
21from core.models import Job
22from core.object_actions import BulkSync
23from dcim.models import Device, DeviceRole, Platform
24from extras.choices import LogLevelChoices
25from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
26from extras.dashboard.utils import get_widget_class
27from extras.scripts import prepare_script_form
28from extras.utils import SharedObjectViewMixin
29from netbox.object_actions import *
30from netbox.ui import layout
31from netbox.ui.panels import (
32 CommentsPanel,
33 ContextTablePanel,
34 JSONPanel,
35 TemplatePanel,
36 TextCodePanel,
37)
38from netbox.views import generic
39from netbox.views.generic.mixins import TableMixin
40from utilities.forms import ConfirmationForm, get_field_value
41from utilities.htmx import htmx_maybe_redirect_current_page, htmx_partial
42from utilities.paginator import EnhancedPaginator, get_paginate_count
43from utilities.query import count_related
44from utilities.querydict import normalize_querydict
45from utilities.request import copy_safe_request
46from utilities.rqworker import any_workers_for_queue
47from utilities.templatetags.builtins.filters import render_markdown
48from utilities.views import ContentTypePermissionRequiredMixin, get_action_url, register_model_view
49from virtualization.models import VirtualMachine
51from . import filtersets, forms, tables
52from .constants import LOG_LEVEL_RANK
53from .models import *
54from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
55from .ui import panels
57#
58# Custom fields
59#
62@register_model_view(CustomField, 'list', path='', detail=False)
63class CustomFieldListView(generic.ObjectListView):
64 queryset = CustomField.objects.select_related('choice_set')
65 filterset = filtersets.CustomFieldFilterSet
66 filterset_form = forms.CustomFieldFilterForm
67 table = tables.CustomFieldTable
70@register_model_view(CustomField)
71class CustomFieldView(generic.ObjectView):
72 queryset = CustomField.objects.select_related('choice_set')
73 template_name = 'generic/object.html'
74 layout = layout.SimpleLayout(
75 left_panels=[
76 panels.CustomFieldPanel(),
77 panels.CustomFieldBehaviorPanel(),
78 CommentsPanel(),
79 ],
80 right_panels=[
81 panels.CustomFieldObjectTypesPanel(),
82 panels.CustomFieldValidationPanel(),
83 panels.CustomFieldRelatedObjectsPanel(),
84 ],
85 )
87 def get_extra_context(self, request, instance):
88 related_models = ()
90 for object_type in instance.object_types.all():
91 related_models += (
92 object_type.model_class().objects.restrict(request.user, 'view').exclude(
93 Q(**{f'custom_field_data__{instance.name}': ''}) |
94 Q(**{f'custom_field_data__{instance.name}': None})
95 ),
96 )
98 return {
99 'related_models': related_models
100 }
103@register_model_view(CustomField, 'add', detail=False)
104@register_model_view(CustomField, 'edit')
105class CustomFieldEditView(generic.ObjectEditView):
106 queryset = CustomField.objects.select_related('choice_set')
107 form = forms.CustomFieldForm
110@register_model_view(CustomField, 'delete')
111class CustomFieldDeleteView(generic.ObjectDeleteView):
112 queryset = CustomField.objects.select_related('choice_set')
115@register_model_view(CustomField, 'bulk_import', path='import', detail=False)
116class CustomFieldBulkImportView(generic.BulkImportView):
117 queryset = CustomField.objects.select_related('choice_set')
118 model_form = forms.CustomFieldImportForm
121@register_model_view(CustomField, 'bulk_edit', path='edit', detail=False)
122class CustomFieldBulkEditView(generic.BulkEditView):
123 queryset = CustomField.objects.select_related('choice_set')
124 filterset = filtersets.CustomFieldFilterSet
125 table = tables.CustomFieldTable
126 form = forms.CustomFieldBulkEditForm
129@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
130class CustomFieldBulkRenameView(generic.BulkRenameView):
131 queryset = CustomField.objects.all()
132 filterset = filtersets.CustomFieldFilterSet
135@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
136class CustomFieldBulkDeleteView(generic.BulkDeleteView):
137 queryset = CustomField.objects.select_related('choice_set')
138 filterset = filtersets.CustomFieldFilterSet
139 table = tables.CustomFieldTable
142#
143# Custom field choices
144#
146@register_model_view(CustomFieldChoiceSet, 'list', path='', detail=False)
147class CustomFieldChoiceSetListView(generic.ObjectListView):
148 queryset = CustomFieldChoiceSet.objects.all()
149 filterset = filtersets.CustomFieldChoiceSetFilterSet
150 filterset_form = forms.CustomFieldChoiceSetFilterForm
151 table = tables.CustomFieldChoiceSetTable
154@register_model_view(CustomFieldChoiceSet)
155class CustomFieldChoiceSetView(generic.ObjectView):
156 queryset = CustomFieldChoiceSet.objects.all()
157 template_name = 'generic/object.html'
158 layout = layout.SimpleLayout(
159 left_panels=[
160 panels.CustomFieldChoiceSetPanel(),
161 ],
162 right_panels=[
163 panels.CustomFieldChoiceSetChoicesPanel(),
164 ],
165 )
167 def get_extra_context(self, request, instance):
169 # Paginate choices list
170 per_page = get_paginate_count(request)
171 try:
172 page_number = request.GET.get('page', 1)
173 except ValueError:
174 page_number = 1
175 choice_rows = [
176 {
177 'value': value,
178 'label': label,
179 'color': instance.get_choice_color(value),
180 }
181 for value, label in instance.choices
182 ]
183 paginator = EnhancedPaginator(choice_rows, per_page)
184 try:
185 choices = paginator.page(page_number)
186 except EmptyPage:
187 choices = paginator.page(paginator.num_pages)
189 return {
190 'paginator': paginator,
191 'choices': choices,
192 }
195@register_model_view(CustomFieldChoiceSet, 'add', detail=False)
196@register_model_view(CustomFieldChoiceSet, 'edit')
197class CustomFieldChoiceSetEditView(generic.ObjectEditView):
198 queryset = CustomFieldChoiceSet.objects.all()
199 form = forms.CustomFieldChoiceSetForm
202@register_model_view(CustomFieldChoiceSet, 'delete')
203class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
204 queryset = CustomFieldChoiceSet.objects.all()
207@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False)
208class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
209 queryset = CustomFieldChoiceSet.objects.all()
210 model_form = forms.CustomFieldChoiceSetImportForm
213@register_model_view(CustomFieldChoiceSet, 'bulk_edit', path='edit', detail=False)
214class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
215 queryset = CustomFieldChoiceSet.objects.all()
216 filterset = filtersets.CustomFieldChoiceSetFilterSet
217 table = tables.CustomFieldChoiceSetTable
218 form = forms.CustomFieldChoiceSetBulkEditForm
221@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
222class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
223 queryset = CustomFieldChoiceSet.objects.all()
224 filterset = filtersets.CustomFieldChoiceSetFilterSet
227@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
228class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
229 queryset = CustomFieldChoiceSet.objects.all()
230 filterset = filtersets.CustomFieldChoiceSetFilterSet
231 table = tables.CustomFieldChoiceSetTable
234#
235# Custom links
236#
238@register_model_view(CustomLink, 'list', path='', detail=False)
239class CustomLinkListView(generic.ObjectListView):
240 queryset = CustomLink.objects.all()
241 filterset = filtersets.CustomLinkFilterSet
242 filterset_form = forms.CustomLinkFilterForm
243 table = tables.CustomLinkTable
246@register_model_view(CustomLink)
247class CustomLinkView(generic.ObjectView):
248 queryset = CustomLink.objects.all()
249 template_name = 'generic/object.html'
250 layout = layout.SimpleLayout(
251 left_panels=[
252 panels.CustomLinkPanel(),
253 panels.ObjectTypesPanel(title=_('Assigned Models')),
254 ],
255 right_panels=[
256 TextCodePanel('link_text', title=_('Link Text')),
257 TextCodePanel('link_url', title=_('Link URL')),
258 ],
259 )
262@register_model_view(CustomLink, 'add', detail=False)
263@register_model_view(CustomLink, 'edit')
264class CustomLinkEditView(generic.ObjectEditView):
265 queryset = CustomLink.objects.all()
266 form = forms.CustomLinkForm
269@register_model_view(CustomLink, 'delete')
270class CustomLinkDeleteView(generic.ObjectDeleteView):
271 queryset = CustomLink.objects.all()
274@register_model_view(CustomLink, 'bulk_import', path='import', detail=False)
275class CustomLinkBulkImportView(generic.BulkImportView):
276 queryset = CustomLink.objects.all()
277 model_form = forms.CustomLinkImportForm
280@register_model_view(CustomLink, 'bulk_edit', path='edit', detail=False)
281class CustomLinkBulkEditView(generic.BulkEditView):
282 queryset = CustomLink.objects.all()
283 filterset = filtersets.CustomLinkFilterSet
284 table = tables.CustomLinkTable
285 form = forms.CustomLinkBulkEditForm
288@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
289class CustomLinkBulkRenameView(generic.BulkRenameView):
290 queryset = CustomLink.objects.all()
291 filterset = filtersets.CustomLinkFilterSet
294@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
295class CustomLinkBulkDeleteView(generic.BulkDeleteView):
296 queryset = CustomLink.objects.all()
297 filterset = filtersets.CustomLinkFilterSet
298 table = tables.CustomLinkTable
301#
302# Export templates
303#
305@register_model_view(ExportTemplate, 'list', path='', detail=False)
306class ExportTemplateListView(generic.ObjectListView):
307 queryset = ExportTemplate.objects.all()
308 filterset = filtersets.ExportTemplateFilterSet
309 filterset_form = forms.ExportTemplateFilterForm
310 table = tables.ExportTemplateTable
311 actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
314@register_model_view(ExportTemplate)
315@method_decorator(never_cache, name='dispatch')
316class ExportTemplateView(generic.ObjectView):
317 queryset = ExportTemplate.objects.all()
318 template_name = 'generic/object.html'
319 layout = layout.SimpleLayout(
320 left_panels=[
321 panels.ExportTemplatePanel(),
322 TemplatePanel('core/inc/datafile_panel.html'),
323 ],
324 right_panels=[
325 panels.ObjectTypesPanel(title=_('Assigned Models')),
326 JSONPanel('environment_params', title=_('Environment Parameters')),
327 ],
328 bottom_panels=[
329 TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
330 ],
331 )
334@register_model_view(ExportTemplate, 'add', detail=False)
335@register_model_view(ExportTemplate, 'edit')
336class ExportTemplateEditView(generic.ObjectEditView):
337 queryset = ExportTemplate.objects.all()
338 form = forms.ExportTemplateForm
341@register_model_view(ExportTemplate, 'delete')
342class ExportTemplateDeleteView(generic.ObjectDeleteView):
343 queryset = ExportTemplate.objects.all()
346@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False)
347class ExportTemplateBulkImportView(generic.BulkImportView):
348 queryset = ExportTemplate.objects.all()
349 model_form = forms.ExportTemplateImportForm
352@register_model_view(ExportTemplate, 'bulk_edit', path='edit', detail=False)
353class ExportTemplateBulkEditView(generic.BulkEditView):
354 queryset = ExportTemplate.objects.all()
355 filterset = filtersets.ExportTemplateFilterSet
356 table = tables.ExportTemplateTable
357 form = forms.ExportTemplateBulkEditForm
360@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
361class ExportTemplateBulkRenameView(generic.BulkRenameView):
362 queryset = ExportTemplate.objects.all()
363 filterset = filtersets.ExportTemplateFilterSet
366@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
367class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
368 queryset = ExportTemplate.objects.all()
369 filterset = filtersets.ExportTemplateFilterSet
370 table = tables.ExportTemplateTable
373@register_model_view(ExportTemplate, 'bulk_sync', path='sync', detail=False)
374class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
375 queryset = ExportTemplate.objects.all()
378#
379# Saved filters
380#
382@register_model_view(SavedFilter, 'list', path='', detail=False)
383class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
384 queryset = SavedFilter.objects.all()
385 filterset = filtersets.SavedFilterFilterSet
386 filterset_form = forms.SavedFilterFilterForm
387 table = tables.SavedFilterTable
390@register_model_view(SavedFilter)
391class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
392 queryset = SavedFilter.objects.all()
393 template_name = 'generic/object.html'
394 layout = layout.SimpleLayout(
395 left_panels=[
396 panels.SavedFilterPanel(),
397 panels.SavedFilterObjectTypesPanel(),
398 ],
399 right_panels=[
400 JSONPanel('parameters', title=_('Parameters')),
401 ],
402 )
405@register_model_view(SavedFilter, 'add', detail=False)
406@register_model_view(SavedFilter, 'edit')
407class SavedFilterEditView(SharedObjectViewMixin, generic.ObjectEditView):
408 queryset = SavedFilter.objects.all()
409 form = forms.SavedFilterForm
411 def alter_object(self, obj, request, url_args, url_kwargs):
412 if not obj.pk:
413 obj.user = request.user
414 return obj
417@register_model_view(SavedFilter, 'delete')
418class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
419 queryset = SavedFilter.objects.all()
422@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
423class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
424 queryset = SavedFilter.objects.all()
425 model_form = forms.SavedFilterImportForm
428@register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False)
429class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
430 queryset = SavedFilter.objects.all()
431 filterset = filtersets.SavedFilterFilterSet
432 table = tables.SavedFilterTable
433 form = forms.SavedFilterBulkEditForm
436@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
437class SavedFilterBulkRenameView(generic.BulkRenameView):
438 queryset = SavedFilter.objects.all()
439 filterset = filtersets.SavedFilterFilterSet
442@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
443class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
444 queryset = SavedFilter.objects.all()
445 filterset = filtersets.SavedFilterFilterSet
446 table = tables.SavedFilterTable
449#
450# Table configs
451#
453@register_model_view(TableConfig, 'list', path='', detail=False)
454class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
455 queryset = TableConfig.objects.all()
456 filterset = filtersets.TableConfigFilterSet
457 filterset_form = forms.TableConfigFilterForm
458 table = tables.TableConfigTable
459 actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
462@register_model_view(TableConfig)
463class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
464 queryset = TableConfig.objects.all()
465 template_name = 'generic/object.html'
466 layout = layout.SimpleLayout(
467 left_panels=[
468 panels.TableConfigPanel(),
469 ],
470 right_panels=[
471 panels.TableConfigColumnsPanel(),
472 panels.TableConfigOrderingPanel(),
473 ],
474 )
476 def get_extra_context(self, request, instance):
477 table = instance.table_class([])
478 return {
479 'columns': dict(table.columns.items()),
480 }
483@register_model_view(TableConfig, 'add', detail=False)
484@register_model_view(TableConfig, 'edit')
485class TableConfigEditView(SharedObjectViewMixin, generic.ObjectEditView):
486 queryset = TableConfig.objects.all()
487 form = forms.TableConfigForm
488 template_name = 'extras/tableconfig_edit.html'
490 def get(self, request, *args, **kwargs):
491 # The add view requires the object_type & table parameters from the source table view
492 if not kwargs and not (request.GET.get('object_type') and request.GET.get('table')):
493 messages.warning(request, _('Table configurations must be created from an object list view.'))
494 return redirect('home')
496 return super().get(request, *args, **kwargs)
498 def alter_object(self, obj, request, url_args, url_kwargs):
499 if not obj.pk:
500 obj.user = request.user
501 return obj
504@register_model_view(TableConfig, 'delete')
505class TableConfigDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
506 queryset = TableConfig.objects.all()
509@register_model_view(TableConfig, 'bulk_edit', path='edit', detail=False)
510class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
511 queryset = TableConfig.objects.all()
512 filterset = filtersets.TableConfigFilterSet
513 table = tables.TableConfigTable
514 form = forms.TableConfigBulkEditForm
517@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
518class TableConfigBulkRenameView(generic.BulkRenameView):
519 queryset = TableConfig.objects.all()
520 filterset = filtersets.TableConfigFilterSet
523@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
524class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
525 queryset = TableConfig.objects.all()
526 filterset = filtersets.TableConfigFilterSet
527 table = tables.TableConfigTable
530#
531# Bookmarks
532#
534@register_model_view(Bookmark, 'add', detail=False)
535class BookmarkCreateView(generic.ObjectEditView):
536 form = forms.BookmarkForm
538 def get_queryset(self, request):
539 return Bookmark.objects.filter(user=request.user)
541 def alter_object(self, obj, request, url_args, url_kwargs):
542 obj.user = request.user
543 return obj
546@register_model_view(Bookmark, 'delete')
547class BookmarkDeleteView(generic.ObjectDeleteView):
549 def get_queryset(self, request):
550 return Bookmark.objects.filter(user=request.user)
553@register_model_view(Bookmark, 'bulk_delete', path='delete', detail=False)
554class BookmarkBulkDeleteView(generic.BulkDeleteView):
555 table = tables.BookmarkTable
557 def get_queryset(self, request):
558 return Bookmark.objects.filter(user=request.user)
561#
562# Notification groups
563#
565@register_model_view(NotificationGroup, 'list', path='', detail=False)
566class NotificationGroupListView(generic.ObjectListView):
567 queryset = NotificationGroup.objects.all()
568 filterset = filtersets.NotificationGroupFilterSet
569 filterset_form = forms.NotificationGroupFilterForm
570 table = tables.NotificationGroupTable
573@register_model_view(NotificationGroup)
574class NotificationGroupView(generic.ObjectView):
575 queryset = NotificationGroup.objects.all()
576 template_name = 'generic/object.html'
577 layout = layout.SimpleLayout(
578 left_panels=[
579 panels.NotificationGroupPanel(),
580 ],
581 right_panels=[
582 panels.NotificationGroupGroupsPanel(),
583 panels.NotificationGroupUsersPanel(),
584 ],
585 )
588@register_model_view(NotificationGroup, 'add', detail=False)
589@register_model_view(NotificationGroup, 'edit')
590class NotificationGroupEditView(generic.ObjectEditView):
591 queryset = NotificationGroup.objects.all()
592 form = forms.NotificationGroupForm
595@register_model_view(NotificationGroup, 'delete')
596class NotificationGroupDeleteView(generic.ObjectDeleteView):
597 queryset = NotificationGroup.objects.all()
600@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False)
601class NotificationGroupBulkImportView(generic.BulkImportView):
602 queryset = NotificationGroup.objects.all()
603 model_form = forms.NotificationGroupImportForm
606@register_model_view(NotificationGroup, 'bulk_edit', path='edit', detail=False)
607class NotificationGroupBulkEditView(generic.BulkEditView):
608 queryset = NotificationGroup.objects.all()
609 filterset = filtersets.NotificationGroupFilterSet
610 table = tables.NotificationGroupTable
611 form = forms.NotificationGroupBulkEditForm
614@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
615class NotificationGroupBulkRenameView(generic.BulkRenameView):
616 queryset = NotificationGroup.objects.all()
617 filterset = filtersets.NotificationGroupFilterSet
620@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
621class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
622 queryset = NotificationGroup.objects.all()
623 filterset = filtersets.NotificationGroupFilterSet
624 table = tables.NotificationGroupTable
627#
628# Notifications
629#
631class NotificationsView(LoginRequiredMixin, View):
632 """
633 HTMX-only user-specific notifications list.
634 """
635 def get(self, request):
636 return render(request, 'htmx/notifications.html', {
637 'notifications': request.user.notifications.unread()[:10],
638 'total_count': request.user.notifications.count(),
639 'unread_count': request.user.notifications.unread().count(),
640 })
643@register_model_view(Notification, 'read')
644class NotificationReadView(LoginRequiredMixin, View):
645 """
646 Mark the Notification read and redirect the user to its attached object.
647 """
649 def get(self, request, pk):
650 # Mark the Notification as read
651 notification = get_object_or_404(request.user.notifications, pk=pk)
652 notification.read = timezone.now()
653 notification.save()
655 # Redirect to the object if it has a URL (deleted objects will not)
656 if hasattr(notification.object, 'get_absolute_url'):
657 return redirect(notification.object.get_absolute_url())
659 return redirect('account:notifications')
662@register_model_view(Notification, name='dismiss_all', path='dismiss-all', detail=False)
663class NotificationDismissAllView(LoginRequiredMixin, View):
664 """
665 Convenience view to clear all *unread* notifications for the current user.
666 """
668 def get(self, request):
669 request.user.notifications.unread().delete()
670 if htmx_partial(request):
671 # If a user is currently on the notification page, redirect there (full repaint)
672 redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True)
673 if redirect_resp:
674 return redirect_resp
676 return render(request, 'htmx/notifications.html', {
677 'notifications': request.user.notifications.unread()[:10],
678 'total_count': request.user.notifications.count(),
679 'unread_count': request.user.notifications.unread().count(),
680 })
681 return redirect('account:notifications')
684@register_model_view(Notification, 'dismiss')
685class NotificationDismissView(LoginRequiredMixin, View):
686 """
687 A convenience view which allows deleting notifications with one click.
688 """
690 def get(self, request, pk):
691 notification = get_object_or_404(request.user.notifications, pk=pk)
692 notification.delete()
694 if htmx_partial(request):
695 # If a user is currently on the notification page, redirect there (full repaint)
696 redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True)
697 if redirect_resp:
698 return redirect_resp
700 return render(request, 'htmx/notifications.html', {
701 'notifications': request.user.notifications.unread()[:10],
702 'total_count': request.user.notifications.count(),
703 'unread_count': request.user.notifications.unread().count(),
704 })
706 return redirect('account:notifications')
709@register_model_view(Notification, 'delete')
710class NotificationDeleteView(generic.ObjectDeleteView):
712 def get_queryset(self, request):
713 return Notification.objects.filter(user=request.user)
716@register_model_view(Notification, 'bulk_delete', path='delete', detail=False)
717class NotificationBulkDeleteView(generic.BulkDeleteView):
718 table = tables.NotificationTable
720 def get_queryset(self, request):
721 return Notification.objects.filter(user=request.user)
724#
725# Subscriptions
726#
728@register_model_view(Subscription, 'add', detail=False)
729class SubscriptionCreateView(generic.ObjectEditView):
730 form = forms.SubscriptionForm
732 def get_queryset(self, request):
733 return Subscription.objects.filter(user=request.user)
735 def alter_object(self, obj, request, url_args, url_kwargs):
736 obj.user = request.user
737 return obj
740@register_model_view(Subscription, 'delete')
741class SubscriptionDeleteView(generic.ObjectDeleteView):
743 def get_queryset(self, request):
744 return Subscription.objects.filter(user=request.user)
747@register_model_view(Subscription, 'bulk_delete', path='delete', detail=False)
748class SubscriptionBulkDeleteView(generic.BulkDeleteView):
749 table = tables.SubscriptionTable
751 def get_queryset(self, request):
752 return Subscription.objects.filter(user=request.user)
755#
756# Webhooks
757#
759@register_model_view(Webhook, 'list', path='', detail=False)
760class WebhookListView(generic.ObjectListView):
761 queryset = Webhook.objects.all()
762 filterset = filtersets.WebhookFilterSet
763 filterset_form = forms.WebhookFilterForm
764 table = tables.WebhookTable
767@register_model_view(Webhook)
768class WebhookView(generic.ObjectView):
769 queryset = Webhook.objects.all()
770 template_name = 'generic/object.html'
771 layout = layout.SimpleLayout(
772 left_panels=[
773 panels.WebhookPanel(),
774 panels.WebhookHTTPPanel(),
775 panels.WebhookSSLPanel(),
776 ],
777 right_panels=[
778 TextCodePanel('additional_headers', title=_('Additional Headers')),
779 TextCodePanel('body_template', title=_('Body Template')),
780 panels.CustomFieldsPanel(),
781 panels.TagsPanel(),
782 ],
783 )
786@register_model_view(Webhook, 'add', detail=False)
787@register_model_view(Webhook, 'edit')
788class WebhookEditView(generic.ObjectEditView):
789 queryset = Webhook.objects.all()
790 form = forms.WebhookForm
793@register_model_view(Webhook, 'delete')
794class WebhookDeleteView(generic.ObjectDeleteView):
795 queryset = Webhook.objects.all()
798@register_model_view(Webhook, 'bulk_import', path='import', detail=False)
799class WebhookBulkImportView(generic.BulkImportView):
800 queryset = Webhook.objects.all()
801 model_form = forms.WebhookImportForm
804@register_model_view(Webhook, 'bulk_edit', path='edit', detail=False)
805class WebhookBulkEditView(generic.BulkEditView):
806 queryset = Webhook.objects.all()
807 filterset = filtersets.WebhookFilterSet
808 table = tables.WebhookTable
809 form = forms.WebhookBulkEditForm
812@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
813class WebhookBulkRenameView(generic.BulkRenameView):
814 queryset = Webhook.objects.all()
815 filterset = filtersets.WebhookFilterSet
818@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
819class WebhookBulkDeleteView(generic.BulkDeleteView):
820 queryset = Webhook.objects.all()
821 filterset = filtersets.WebhookFilterSet
822 table = tables.WebhookTable
825#
826# Event Rules
827#
829@register_model_view(EventRule, 'list', path='', detail=False)
830class EventRuleListView(generic.ObjectListView):
831 queryset = EventRule.objects.all()
832 filterset = filtersets.EventRuleFilterSet
833 filterset_form = forms.EventRuleFilterForm
834 table = tables.EventRuleTable
837@register_model_view(EventRule)
838class EventRuleView(generic.ObjectView):
839 queryset = EventRule.objects.all()
840 template_name = 'generic/object.html'
841 layout = layout.SimpleLayout(
842 left_panels=[
843 panels.EventRulePanel(),
844 panels.ObjectTypesPanel(),
845 panels.EventRuleEventTypesPanel(),
846 ],
847 right_panels=[
848 JSONPanel('conditions', title=_('Conditions')),
849 panels.EventRuleActionPanel(),
850 panels.CustomFieldsPanel(),
851 panels.TagsPanel(),
852 ],
853 )
856@register_model_view(EventRule, 'add', detail=False)
857@register_model_view(EventRule, 'edit')
858class EventRuleEditView(generic.ObjectEditView):
859 queryset = EventRule.objects.all()
860 form = forms.EventRuleForm
863@register_model_view(EventRule, 'delete')
864class EventRuleDeleteView(generic.ObjectDeleteView):
865 queryset = EventRule.objects.all()
868@register_model_view(EventRule, 'bulk_import', path='import', detail=False)
869class EventRuleBulkImportView(generic.BulkImportView):
870 queryset = EventRule.objects.all()
871 model_form = forms.EventRuleImportForm
874@register_model_view(EventRule, 'bulk_edit', path='edit', detail=False)
875class EventRuleBulkEditView(generic.BulkEditView):
876 queryset = EventRule.objects.all()
877 filterset = filtersets.EventRuleFilterSet
878 table = tables.EventRuleTable
879 form = forms.EventRuleBulkEditForm
882@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
883class EventRuleBulkRenameView(generic.BulkRenameView):
884 queryset = EventRule.objects.all()
885 filterset = filtersets.EventRuleFilterSet
888@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
889class EventRuleBulkDeleteView(generic.BulkDeleteView):
890 queryset = EventRule.objects.all()
891 filterset = filtersets.EventRuleFilterSet
892 table = tables.EventRuleTable
895#
896# Tags
897#
899@register_model_view(Tag, 'list', path='', detail=False)
900class TagListView(generic.ObjectListView):
901 queryset = Tag.objects.annotate(
902 items=count_related(TaggedItem, 'tag')
903 )
904 filterset = filtersets.TagFilterSet
905 filterset_form = forms.TagFilterForm
906 table = tables.TagTable
909@register_model_view(Tag)
910class TagView(generic.ObjectView):
911 queryset = Tag.objects.all()
912 template_name = 'generic/object.html'
913 layout = layout.SimpleLayout(
914 left_panels=[
915 panels.TagPanel(),
916 ],
917 right_panels=[
918 panels.TagObjectTypesPanel(),
919 panels.TagItemTypesPanel(),
920 ],
921 bottom_panels=[
922 ContextTablePanel('taggeditem_table', title=_('Tagged Objects')),
923 ],
924 )
926 def get_extra_context(self, request, instance):
927 tagged_items = TaggedItem.objects.filter(tag=instance)
928 taggeditem_table = tables.TaggedItemTable(
929 data=tagged_items,
930 orderable=False
931 )
932 taggeditem_table.configure(request)
934 object_types = [
935 {
936 'content_type': ContentType.objects.get(pk=ti['content_type']),
937 'item_count': ti['item_count']
938 } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk'))
939 ]
941 return {
942 'taggeditem_table': taggeditem_table,
943 'tagged_item_count': tagged_items.count(),
944 'object_types': object_types,
945 }
948@register_model_view(Tag, 'add', detail=False)
949@register_model_view(Tag, 'edit')
950class TagEditView(generic.ObjectEditView):
951 queryset = Tag.objects.all()
952 form = forms.TagForm
955@register_model_view(Tag, 'delete')
956class TagDeleteView(generic.ObjectDeleteView):
957 queryset = Tag.objects.all()
960@register_model_view(Tag, 'bulk_import', path='import', detail=False)
961class TagBulkImportView(generic.BulkImportView):
962 queryset = Tag.objects.all()
963 model_form = forms.TagImportForm
966@register_model_view(Tag, 'bulk_edit', path='edit', detail=False)
967class TagBulkEditView(generic.BulkEditView):
968 queryset = Tag.objects.annotate(
969 items=count_related(TaggedItem, 'tag')
970 )
971 table = tables.TagTable
972 form = forms.TagBulkEditForm
975@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
976class TagBulkRenameView(generic.BulkRenameView):
977 queryset = Tag.objects.all()
980@register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
981class TagBulkDeleteView(generic.BulkDeleteView):
982 queryset = Tag.objects.annotate(
983 items=count_related(TaggedItem, 'tag')
984 )
985 table = tables.TagTable
988#
989# Config context profiles
990#
992@register_model_view(ConfigContextProfile, 'list', path='', detail=False)
993class ConfigContextProfileListView(generic.ObjectListView):
994 queryset = ConfigContextProfile.objects.all()
995 filterset = filtersets.ConfigContextProfileFilterSet
996 filterset_form = forms.ConfigContextProfileFilterForm
997 table = tables.ConfigContextProfileTable
998 actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
1001@register_model_view(ConfigContextProfile)
1002@method_decorator(never_cache, name='dispatch')
1003class ConfigContextProfileView(generic.ObjectView):
1004 queryset = ConfigContextProfile.objects.all()
1005 template_name = 'generic/object.html'
1006 layout = layout.SimpleLayout(
1007 left_panels=[
1008 panels.ConfigContextProfilePanel(),
1009 TemplatePanel('core/inc/datafile_panel.html'),
1010 panels.CustomFieldsPanel(),
1011 panels.TagsPanel(),
1012 CommentsPanel(),
1013 ],
1014 right_panels=[
1015 JSONPanel('schema', title=_('JSON Schema')),
1016 ],
1017 )
1020@register_model_view(ConfigContextProfile, 'add', detail=False)
1021@register_model_view(ConfigContextProfile, 'edit')
1022class ConfigContextProfileEditView(generic.ObjectEditView):
1023 queryset = ConfigContextProfile.objects.all()
1024 form = forms.ConfigContextProfileForm
1027@register_model_view(ConfigContextProfile, 'delete')
1028class ConfigContextProfileDeleteView(generic.ObjectDeleteView):
1029 queryset = ConfigContextProfile.objects.all()
1032@register_model_view(ConfigContextProfile, 'bulk_import', path='import', detail=False)
1033class ConfigContextProfileBulkImportView(generic.BulkImportView):
1034 queryset = ConfigContextProfile.objects.all()
1035 model_form = forms.ConfigContextProfileImportForm
1038@register_model_view(ConfigContextProfile, 'bulk_edit', path='edit', detail=False)
1039class ConfigContextProfileBulkEditView(generic.BulkEditView):
1040 queryset = ConfigContextProfile.objects.all()
1041 filterset = filtersets.ConfigContextProfileFilterSet
1042 table = tables.ConfigContextProfileTable
1043 form = forms.ConfigContextProfileBulkEditForm
1046@register_model_view(ConfigContextProfile, 'bulk_rename', path='rename', detail=False)
1047class ConfigContextProfileBulkRenameView(generic.BulkRenameView):
1048 queryset = ConfigContextProfile.objects.all()
1049 filterset = filtersets.ConfigContextProfileFilterSet
1052@register_model_view(ConfigContextProfile, 'bulk_delete', path='delete', detail=False)
1053class ConfigContextProfileBulkDeleteView(generic.BulkDeleteView):
1054 queryset = ConfigContextProfile.objects.all()
1055 filterset = filtersets.ConfigContextProfileFilterSet
1056 table = tables.ConfigContextProfileTable
1059@register_model_view(ConfigContextProfile, 'bulk_sync', path='sync', detail=False)
1060class ConfigContextProfileBulkSyncDataView(generic.BulkSyncDataView):
1061 queryset = ConfigContextProfile.objects.all()
1064#
1065# Config contexts
1066#
1068@register_model_view(ConfigContext, 'list', path='', detail=False)
1069class ConfigContextListView(generic.ObjectListView):
1070 queryset = ConfigContext.objects.all()
1071 filterset = filtersets.ConfigContextFilterSet
1072 filterset_form = forms.ConfigContextFilterForm
1073 table = tables.ConfigContextTable
1074 actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
1077@register_model_view(ConfigContext)
1078@method_decorator(never_cache, name='dispatch')
1079class ConfigContextView(generic.ObjectView):
1080 queryset = ConfigContext.objects.all()
1081 template_name = 'generic/object.html'
1082 layout = layout.SimpleLayout(
1083 left_panels=[
1084 panels.ConfigContextPanel(),
1085 TemplatePanel('core/inc/datafile_panel.html'),
1086 panels.ConfigContextAssignmentPanel(),
1087 ],
1088 right_panels=[
1089 TemplatePanel('extras/panels/configcontext_data.html'),
1090 ],
1091 )
1093 def get_extra_context(self, request, instance):
1094 # Gather assigned objects for parsing in the template
1095 assigned_objects = (
1096 ('Regions', instance.regions.all),
1097 ('Site Groups', instance.site_groups.all),
1098 ('Sites', instance.sites.all),
1099 ('Locations', instance.locations.all),
1100 ('Device Types', instance.device_types.all),
1101 ('Roles', instance.roles.all),
1102 ('Platforms', instance.platforms.all),
1103 ('Cluster Types', instance.cluster_types.all),
1104 ('Cluster Groups', instance.cluster_groups.all),
1105 ('Clusters', instance.clusters.all),
1106 ('Tenant Groups', instance.tenant_groups.all),
1107 ('Tenants', instance.tenants.all),
1108 ('Tags', instance.tags.all),
1109 )
1111 # Determine user's preferred output format
1112 if request.GET.get('format') in ['json', 'yaml']:
1113 format = request.GET.get('format')
1114 if request.user.is_authenticated:
1115 request.user.config.set('data_format', format, commit=True)
1116 elif request.user.is_authenticated:
1117 format = request.user.config.get('data_format', 'json')
1118 else:
1119 format = 'json'
1121 return {
1122 'assigned_objects': assigned_objects,
1123 'format': format,
1124 }
1127@register_model_view(ConfigContext, 'add', detail=False)
1128@register_model_view(ConfigContext, 'edit')
1129class ConfigContextEditView(generic.ObjectEditView):
1130 queryset = ConfigContext.objects.all()
1131 form = forms.ConfigContextForm
1134@register_model_view(ConfigContext, 'delete')
1135class ConfigContextDeleteView(generic.ObjectDeleteView):
1136 queryset = ConfigContext.objects.all()
1139@register_model_view(ConfigContext, 'bulk_edit', path='edit', detail=False)
1140class ConfigContextBulkEditView(generic.BulkEditView):
1141 queryset = ConfigContext.objects.all()
1142 filterset = filtersets.ConfigContextFilterSet
1143 table = tables.ConfigContextTable
1144 form = forms.ConfigContextBulkEditForm
1147@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
1148class ConfigContextBulkRenameView(generic.BulkRenameView):
1149 queryset = ConfigContext.objects.all()
1150 filterset = filtersets.ConfigContextFilterSet
1153@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
1154class ConfigContextBulkDeleteView(generic.BulkDeleteView):
1155 queryset = ConfigContext.objects.all()
1156 filterset = filtersets.ConfigContextFilterSet
1157 table = tables.ConfigContextTable
1160@register_model_view(ConfigContext, 'bulk_sync', path='sync', detail=False)
1161class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
1162 queryset = ConfigContext.objects.all()
1165@method_decorator(never_cache, name='dispatch')
1166class ObjectConfigContextView(generic.ObjectView):
1167 base_template = None
1168 template_name = 'extras/object_configcontext.html'
1170 def get_extra_context(self, request, instance):
1171 source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance)
1173 # Determine user's preferred output format
1174 if request.GET.get('format') in ['json', 'yaml']:
1175 format = request.GET.get('format')
1176 if request.user.is_authenticated:
1177 request.user.config.set('data_format', format, commit=True)
1178 elif request.user.is_authenticated:
1179 format = request.user.config.get('data_format', 'json')
1180 else:
1181 format = 'json'
1183 return {
1184 'rendered_context': instance.get_config_context(),
1185 'source_contexts': source_contexts,
1186 'format': format,
1187 'base_template': self.base_template,
1188 }
1191#
1192# Config templates
1193#
1195@register_model_view(ConfigTemplate, 'list', path='', detail=False)
1196class ConfigTemplateListView(generic.ObjectListView):
1197 queryset = ConfigTemplate.objects.annotate(
1198 device_count=count_related(Device, 'config_template'),
1199 vm_count=count_related(VirtualMachine, 'config_template'),
1200 role_count=count_related(DeviceRole, 'config_template'),
1201 platform_count=count_related(Platform, 'config_template'),
1202 )
1203 filterset = filtersets.ConfigTemplateFilterSet
1204 filterset_form = forms.ConfigTemplateFilterForm
1205 table = tables.ConfigTemplateTable
1206 actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
1209@register_model_view(ConfigTemplate)
1210@method_decorator(never_cache, name='dispatch')
1211class ConfigTemplateView(generic.ObjectView):
1212 queryset = ConfigTemplate.objects.all()
1213 template_name = 'generic/object.html'
1214 layout = layout.SimpleLayout(
1215 left_panels=[
1216 panels.ConfigTemplatePanel(),
1217 panels.TagsPanel(),
1218 ],
1219 right_panels=[
1220 JSONPanel('environment_params', title=_('Environment Parameters')),
1221 ],
1222 bottom_panels=[
1223 TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
1224 ],
1225 )
1228@register_model_view(ConfigTemplate, 'add', detail=False)
1229@register_model_view(ConfigTemplate, 'edit')
1230class ConfigTemplateEditView(generic.ObjectEditView):
1231 queryset = ConfigTemplate.objects.all()
1232 form = forms.ConfigTemplateForm
1235@register_model_view(ConfigTemplate, 'delete')
1236class ConfigTemplateDeleteView(generic.ObjectDeleteView):
1237 queryset = ConfigTemplate.objects.all()
1240@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False)
1241class ConfigTemplateBulkImportView(generic.BulkImportView):
1242 queryset = ConfigTemplate.objects.all()
1243 model_form = forms.ConfigTemplateImportForm
1246@register_model_view(ConfigTemplate, 'bulk_edit', path='edit', detail=False)
1247class ConfigTemplateBulkEditView(generic.BulkEditView):
1248 queryset = ConfigTemplate.objects.all()
1249 filterset = filtersets.ConfigTemplateFilterSet
1250 table = tables.ConfigTemplateTable
1251 form = forms.ConfigTemplateBulkEditForm
1254@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
1255class ConfigTemplateBulkRenameView(generic.BulkRenameView):
1256 queryset = ConfigTemplate.objects.all()
1257 filterset = filtersets.ConfigTemplateFilterSet
1260@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
1261class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
1262 queryset = ConfigTemplate.objects.all()
1263 filterset = filtersets.ConfigTemplateFilterSet
1264 table = tables.ConfigTemplateTable
1267@register_model_view(ConfigTemplate, 'bulk_sync', path='sync', detail=False)
1268class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
1269 queryset = ConfigTemplate.objects.all()
1272@method_decorator(never_cache, name='dispatch')
1273class ObjectRenderConfigView(generic.ObjectView):
1274 base_template = None
1275 template_name = 'extras/object_render_config.html'
1277 def get(self, request, **kwargs):
1278 instance = self.get_object(**kwargs)
1279 context = self.get_extra_context(request, instance)
1281 # If a direct export has been requested, return the rendered template content as a
1282 # downloadable file.
1283 if request.GET.get('export'):
1284 content = context['rendered_config'] or context['error_message']
1285 response = HttpResponse(content, content_type='text')
1286 filename = f"{instance.name or 'config'}.txt"
1287 response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
1288 return response
1290 return render(
1291 request,
1292 self.get_template_name(),
1293 {
1294 'object': instance,
1295 'tab': self.tab,
1296 **context,
1297 },
1298 )
1300 def get_extra_context_data(self, request, instance):
1301 return {
1302 f'{instance._meta.model_name}': instance,
1303 }
1305 def get_extra_context(self, request, instance):
1306 # Compile context data
1307 context_data = instance.get_config_context()
1308 context_data.update(self.get_extra_context_data(request, instance))
1310 # Check for an optional config_template_id override in the query params
1311 config_template = None
1312 error_message = ''
1313 if config_template_id := request.GET.get('config_template_id'):
1314 try:
1315 config_template = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id)
1316 except (ConfigTemplate.DoesNotExist, ValueError):
1317 error_message = _("Config template with ID {id} not found.").format(id=config_template_id)
1318 else:
1319 config_template = instance.get_config_template()
1321 # Render the config template
1322 rendered_config = None
1323 if config_template:
1324 try:
1325 rendered_config = config_template.render(context=context_data)
1326 except Exception as e:
1327 error_message = config_template.format_render_error(e)
1329 return {
1330 'base_template': self.base_template,
1331 'config_template': config_template,
1332 'context_data': context_data,
1333 'rendered_config': rendered_config,
1334 'error_message': error_message,
1335 }
1338#
1339# Image attachments
1340#
1342@register_model_view(ImageAttachment, 'list', path='', detail=False)
1343class ImageAttachmentListView(generic.ObjectListView):
1344 queryset = ImageAttachment.objects.all()
1345 filterset = filtersets.ImageAttachmentFilterSet
1346 filterset_form = forms.ImageAttachmentFilterForm
1347 table = tables.ImageAttachmentTable
1348 actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
1351@register_model_view(ImageAttachment)
1352class ImageAttachmentView(generic.ObjectView):
1353 queryset = ImageAttachment.objects.all()
1354 template_name = 'generic/object.html'
1355 layout = layout.SimpleLayout(
1356 left_panels=[
1357 panels.ImageAttachmentPanel(),
1358 ],
1359 right_panels=[
1360 panels.ImageAttachmentFilePanel(),
1361 ],
1362 bottom_panels=[
1363 panels.ImageAttachmentImagePanel(),
1364 ],
1365 )
1368@register_model_view(ImageAttachment, 'add', detail=False)
1369@register_model_view(ImageAttachment, 'edit')
1370class ImageAttachmentEditView(generic.ObjectEditView):
1371 queryset = ImageAttachment.objects.all()
1372 form = forms.ImageAttachmentForm
1374 def alter_object(self, instance, request, args, kwargs):
1375 if not instance.pk:
1376 # Assign the parent object based on URL kwargs
1377 object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type'))
1378 instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
1379 return instance
1381 def get_extra_addanother_params(self, request):
1382 return {
1383 'object_type': request.GET.get('object_type'),
1384 'object_id': request.GET.get('object_id'),
1385 }
1388@register_model_view(ImageAttachment, 'delete')
1389class ImageAttachmentDeleteView(generic.ObjectDeleteView):
1390 queryset = ImageAttachment.objects.all()
1393@register_model_view(ImageAttachment, 'bulk_edit', path='edit', detail=False)
1394class ImageAttachmentBulkEditView(generic.BulkEditView):
1395 queryset = ImageAttachment.objects.all()
1396 filterset = filtersets.ImageAttachmentFilterSet
1397 table = tables.ImageAttachmentTable
1398 form = forms.ImageAttachmentBulkEditForm
1401@register_model_view(ImageAttachment, 'bulk_rename', path='rename', detail=False)
1402class ImageAttachmentBulkRenameView(generic.BulkRenameView):
1403 queryset = ImageAttachment.objects.all()
1404 filterset = filtersets.ImageAttachmentFilterSet
1407@register_model_view(ImageAttachment, 'bulk_delete', path='delete', detail=False)
1408class ImageAttachmentBulkDeleteView(generic.BulkDeleteView):
1409 queryset = ImageAttachment.objects.all()
1410 filterset = filtersets.ImageAttachmentFilterSet
1411 table = tables.ImageAttachmentTable
1414#
1415# Journal entries
1416#
1418@register_model_view(JournalEntry, 'list', path='', detail=False)
1419class JournalEntryListView(generic.ObjectListView):
1420 queryset = JournalEntry.objects.all()
1421 filterset = filtersets.JournalEntryFilterSet
1422 filterset_form = forms.JournalEntryFilterForm
1423 table = tables.JournalEntryTable
1424 actions = (BulkImport, BulkEdit, BulkDelete)
1427@register_model_view(JournalEntry)
1428class JournalEntryView(generic.ObjectView):
1429 queryset = JournalEntry.objects.all()
1430 layout = layout.SimpleLayout(
1431 left_panels=[
1432 panels.JournalEntryPanel(),
1433 panels.CustomFieldsPanel(),
1434 panels.TagsPanel(),
1435 ],
1436 right_panels=[
1437 CommentsPanel(),
1438 ],
1439 )
1442@register_model_view(JournalEntry, 'add', detail=False)
1443@register_model_view(JournalEntry, 'edit')
1444class JournalEntryEditView(generic.ObjectEditView):
1445 queryset = JournalEntry.objects.all()
1446 form = forms.JournalEntryForm
1448 def alter_object(self, obj, request, args, kwargs):
1449 if not obj.pk:
1450 obj.created_by = request.user
1451 return obj
1453 def get_return_url(self, request, instance):
1454 if not instance.assigned_object:
1455 return reverse('extras:journalentry_list')
1456 obj = instance.assigned_object
1457 return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
1460@register_model_view(JournalEntry, 'delete')
1461class JournalEntryDeleteView(generic.ObjectDeleteView):
1462 queryset = JournalEntry.objects.all()
1464 def get_return_url(self, request, instance):
1465 obj = instance.assigned_object
1466 return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
1469@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)
1470class JournalEntryBulkImportView(generic.BulkImportView):
1471 queryset = JournalEntry.objects.all()
1472 model_form = forms.JournalEntryImportForm
1475@register_model_view(JournalEntry, 'bulk_edit', path='edit', detail=False)
1476class JournalEntryBulkEditView(generic.BulkEditView):
1477 queryset = JournalEntry.objects.all()
1478 filterset = filtersets.JournalEntryFilterSet
1479 table = tables.JournalEntryTable
1480 form = forms.JournalEntryBulkEditForm
1483@register_model_view(JournalEntry, 'bulk_delete', path='delete', detail=False)
1484class JournalEntryBulkDeleteView(generic.BulkDeleteView):
1485 queryset = JournalEntry.objects.all()
1486 filterset = filtersets.JournalEntryFilterSet
1487 table = tables.JournalEntryTable
1490#
1491# Dashboard & widgets
1492#
1494class DashboardResetView(LoginRequiredMixin, View):
1495 template_name = 'extras/dashboard/reset.html'
1497 def get(self, request):
1498 get_object_or_404(Dashboard.objects.all(), user=request.user)
1499 form = ConfirmationForm()
1501 return render(request, self.template_name, {
1502 'form': form,
1503 'return_url': reverse('home'),
1504 })
1506 def post(self, request):
1507 dashboard = get_object_or_404(Dashboard.objects.all(), user=request.user)
1508 form = ConfirmationForm(request.POST)
1510 if form.is_valid():
1511 dashboard.delete()
1512 messages.success(request, _("Your dashboard has been reset."))
1513 return redirect(reverse('home'))
1515 return render(request, self.template_name, {
1516 'form': form,
1517 'return_url': reverse('home'),
1518 })
1521class DashboardWidgetAddView(LoginRequiredMixin, View):
1522 template_name = 'extras/dashboard/widget_add.html'
1524 def get(self, request):
1525 if not request.htmx:
1526 return redirect('home')
1528 initial = {
1529 'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget',
1530 }
1531 widget_form = DashboardWidgetAddForm(initial=initial)
1532 widget_name = get_field_value(widget_form, 'widget_class')
1533 widget_class = get_widget_class(widget_name)
1534 config_form = widget_class.ConfigForm(initial=widget_class.default_config, prefix='config')
1536 return render(request, self.template_name, {
1537 'widget_class': widget_class,
1538 'widget_form': widget_form,
1539 'config_form': config_form,
1540 })
1542 def post(self, request):
1543 widget_form = DashboardWidgetAddForm(request.POST)
1544 config_form = None
1545 widget_class = None
1547 if widget_form.is_valid():
1548 widget_class = get_widget_class(widget_form.cleaned_data['widget_class'])
1549 config_form = widget_class.ConfigForm(request.POST, prefix='config')
1551 if config_form.is_valid():
1552 data = widget_form.cleaned_data
1553 data.pop('widget_class')
1554 data['config'] = config_form.cleaned_data
1555 widget = widget_class(**data)
1556 request.user.dashboard.add_widget(widget)
1557 request.user.dashboard.save()
1558 messages.success(request, _('Added widget: ') + str(widget.id))
1560 return HttpResponse(headers={
1561 'HX-Redirect': reverse('home'),
1562 })
1564 return render(request, self.template_name, {
1565 'widget_class': widget_class,
1566 'widget_form': widget_form,
1567 'config_form': config_form,
1568 })
1571class DashboardWidgetConfigView(LoginRequiredMixin, View):
1572 template_name = 'extras/dashboard/widget_config.html'
1574 def get(self, request, id):
1575 if not request.htmx:
1576 return redirect('home')
1578 widget = request.user.dashboard.get_widget(id)
1579 widget_form = DashboardWidgetForm(initial=widget.form_data)
1580 config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config')
1582 return render(request, self.template_name, {
1583 'widget_class': widget.__class__,
1584 'widget_form': widget_form,
1585 'config_form': config_form,
1586 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
1587 })
1589 def post(self, request, id):
1590 widget = request.user.dashboard.get_widget(id)
1591 widget_form = DashboardWidgetForm(request.POST)
1592 config_form = widget.ConfigForm(request.POST, prefix='config')
1594 if widget_form.is_valid() and config_form.is_valid():
1595 data = widget_form.cleaned_data
1596 data['config'] = config_form.cleaned_data
1597 request.user.dashboard.config[str(id)].update(data)
1598 request.user.dashboard.save()
1599 messages.success(request, _('Updated widget: ') + str(widget.id))
1601 return HttpResponse(headers={
1602 'HX-Redirect': reverse('home'),
1603 })
1605 return render(request, self.template_name, {
1606 'widget_form': widget_form,
1607 'config_form': config_form,
1608 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
1609 })
1612class DashboardWidgetDeleteView(LoginRequiredMixin, View):
1613 template_name = 'generic/object_delete.html'
1615 def get(self, request, id):
1616 if not request.htmx:
1617 return redirect('home')
1619 widget = request.user.dashboard.get_widget(id)
1620 form = ConfirmationForm(initial=request.GET)
1622 return render(request, 'htmx/delete_form.html', {
1623 'object_type': widget.__class__.__name__,
1624 'object': widget,
1625 'form': form,
1626 'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id})
1627 })
1629 def post(self, request, id):
1630 form = ConfirmationForm(request.POST)
1632 if form.is_valid():
1633 request.user.dashboard.delete_widget(id)
1634 request.user.dashboard.save()
1635 messages.success(request, _('Deleted widget: ') + str(id))
1636 else:
1637 messages.error(request, _('Error deleting widget: ') + str(form.errors[0]))
1639 return redirect(reverse('home'))
1642#
1643# Scripts
1644#
1646@register_model_view(ScriptModule, 'edit')
1647class ScriptModuleCreateView(generic.ObjectEditView):
1648 queryset = ScriptModule.objects.all()
1649 form = forms.ScriptFileForm
1650 default_return_url = 'extras:script_list'
1652 def alter_object(self, obj, *args, **kwargs):
1653 obj.file_root = ManagedFileRootPathChoices.SCRIPTS
1654 return obj
1657@register_model_view(ScriptModule, 'delete')
1658class ScriptModuleDeleteView(generic.ObjectDeleteView):
1659 queryset = ScriptModule.objects.all()
1660 default_return_url = 'extras:script_list'
1663class ScriptListView(ContentTypePermissionRequiredMixin, View):
1665 def get_required_permission(self):
1666 return 'extras.view_script'
1668 def get(self, request):
1669 available_scripts = Script.objects.restrict(request.user)
1670 module_ids = {s.module_id for s in available_scripts}
1671 script_modules = ScriptModule.objects.restrict(request.user).filter(pk__in=module_ids).prefetch_related(
1672 'data_source', 'data_file',
1673 )
1675 context = {
1676 'model': ScriptModule,
1677 'script_modules': script_modules,
1678 'available_scripts': available_scripts,
1679 }
1681 # Use partial template for dashboard widgets
1682 template_name = 'extras/script_list.html'
1683 if request.GET.get('embedded'):
1684 template_name = 'extras/inc/script_list_content.html'
1685 context['embedded'] = True
1687 return render(request, template_name, context)
1690class BaseScriptView(generic.ObjectView):
1691 queryset = Script.objects.all()
1693 def get_object(self, **kwargs):
1694 if pk := kwargs.get('pk', False):
1695 return get_object_or_404(self.queryset, pk=pk)
1696 if (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
1697 return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
1698 raise Http404
1700 def _get_script_class(self, script):
1701 """
1702 Return an instance of the Script's Python class
1703 """
1704 if script_class := script.python_class:
1705 return script_class()
1706 return None
1709class ScriptView(BaseScriptView):
1711 def get(self, request, **kwargs):
1712 script = self.get_object(**kwargs)
1713 script_class = self._get_script_class(script)
1714 if not script_class:
1715 return render(request, 'extras/script.html', {
1716 'object': script,
1717 'script': script,
1718 })
1720 form = script_class.as_form(initial=normalize_querydict(request.GET))
1722 return render(request, 'extras/script.html', {
1723 'object': script,
1724 'script': script,
1725 'script_class': script_class,
1726 'form': form,
1727 'job_count': script.jobs.count(),
1728 })
1730 def post(self, request, **kwargs):
1731 script = self.get_object(**kwargs)
1733 if not request.user.has_perm('extras.run_script', obj=script):
1734 return HttpResponseForbidden()
1736 script_class = self._get_script_class(script)
1737 if not script_class:
1738 return render(request, 'extras/script.html', {
1739 'object': script,
1740 'script': script,
1741 })
1743 form = prepare_script_form(script_class, request.POST, request.FILES)
1745 # Allow execution only if RQ worker process is running
1746 if not any_workers_for_queue('default'):
1747 messages.error(request, _("Unable to run script: RQ worker process not running."))
1748 elif form.is_valid():
1749 ScriptJob = import_string("extras.jobs.ScriptJob")
1750 try:
1751 job = ScriptJob.enqueue(
1752 instance=script,
1753 user=request.user,
1754 schedule_at=form.cleaned_data.pop('_schedule_at'),
1755 interval=form.cleaned_data.pop('_interval'),
1756 notifications=form.cleaned_data.pop('_notifications'),
1757 data=form.cleaned_data,
1758 request=copy_safe_request(request),
1759 job_timeout=script.python_class.job_timeout,
1760 commit=form.cleaned_data.pop('_commit'),
1761 )
1762 except ValidationError as e:
1763 # The script's Meta configuration is invalid (see #22872). Surface it as a form error rather than
1764 # allowing the exception to bubble up as an HTTP 500.
1765 for msg in e.messages:
1766 messages.error(request, _("Unable to run script: {error}").format(error=msg))
1767 else:
1768 return redirect('extras:script_result', job_pk=job.pk)
1769 else:
1770 fieldset_fields = {field for _, fields in script_class.get_fieldsets() for field in fields}
1771 hidden_errors = {
1772 field: errors for field, errors in form.errors.items()
1773 if field not in fieldset_fields
1774 }
1775 if hidden_errors:
1776 error_msg = '; '.join(f"{field}: {', '.join(errors)}" for field, errors in hidden_errors.items())
1777 messages.error(request, error_msg)
1779 return render(request, 'extras/script.html', {
1780 'object': script,
1781 'script': script,
1782 'script_class': script.python_class(),
1783 'form': form,
1784 'job_count': script.jobs.count(),
1785 })
1788class ScriptSourceView(BaseScriptView):
1789 queryset = Script.objects.all()
1791 def get(self, request, **kwargs):
1792 script = self.get_object(**kwargs)
1793 script_class = self._get_script_class(script)
1795 return render(request, 'extras/script/source.html', {
1796 'script': script,
1797 'script_class': script_class,
1798 'job_count': script.jobs.count(),
1799 'tab': 'source',
1800 })
1803class ScriptJobsView(BaseScriptView):
1804 queryset = Script.objects.all()
1806 def get(self, request, **kwargs):
1807 script = self.get_object(**kwargs)
1809 jobs_table = ScriptJobTable(data=script.jobs.all(), orderable=False)
1810 jobs_table.configure(request)
1812 return render(request, 'extras/script/jobs.html', {
1813 'script': script,
1814 'table': jobs_table,
1815 'job_count': script.jobs.count(),
1816 'tab': 'jobs',
1817 })
1820class ScriptResultView(TableMixin, generic.ObjectView):
1821 queryset = Job.objects.all()
1823 def get_required_permission(self):
1824 return 'extras.view_script'
1826 def get_queryset(self, request):
1827 return Job.objects.restrict(request.user, 'view')
1829 def get_table(self, job, request, bulk_actions=True):
1830 data = []
1831 tests = None
1832 table = None
1833 index = 0
1835 try:
1836 log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)]
1837 except KeyError:
1838 log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
1839 if job.data:
1840 if 'log' in job.data:
1841 if 'tests' in job.data:
1842 tests = job.data['tests']
1844 for log in job.data['log']:
1845 log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO)
1846 if log_level >= log_threshold:
1847 index += 1
1848 result = {
1849 'index': index,
1850 'time': datetime.fromisoformat(log.get('time')),
1851 'status': log.get('status'),
1852 'message': log.get('message'),
1853 'object': log.get('obj'),
1854 'url': log.get('url'),
1855 }
1856 data.append(result)
1858 table = ScriptResultsTable(data)
1859 table.configure(request)
1860 else:
1861 # for legacy reports
1862 tests = job.data
1864 if tests:
1865 for method, test_data in tests.items():
1866 if 'log' in test_data:
1867 for time, status, obj, url, message in test_data['log']:
1868 log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO)
1869 if log_level >= log_threshold:
1870 index += 1
1871 result = {
1872 'index': index,
1873 'method': method,
1874 'time': time,
1875 'status': status,
1876 'object': obj,
1877 'url': url,
1878 'message': message,
1879 }
1880 data.append(result)
1882 table = ReportResultsTable(data)
1883 table.configure(request)
1885 return table
1887 def get(self, request, **kwargs):
1888 table = None
1889 job = get_object_or_404(self.queryset, pk=kwargs.get('job_pk'))
1891 # If a direct export output has been requested, return the job data content as a
1892 # downloadable file.
1893 if job.completed and request.GET.get('export') == 'output':
1894 content = (job.data.get("output") or "").encode()
1895 response = HttpResponse(content, content_type='text')
1896 filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
1897 response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
1898 return response
1900 if job.completed:
1901 table = self.get_table(job, request, bulk_actions=False)
1903 log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
1904 if log_threshold not in LOG_LEVEL_RANK:
1905 log_threshold = LogLevelChoices.LOG_INFO
1907 context = {
1908 'script': job.object,
1909 'job': job,
1910 'table': table,
1911 'log_levels': dict(LogLevelChoices),
1912 'log_threshold': log_threshold,
1913 }
1915 if job.data and 'log' in job.data:
1916 # Script
1917 context['tests'] = job.data.get('tests', {})
1918 elif job.data:
1919 # Legacy Report
1920 context['tests'] = {
1921 name: data for name, data in job.data.items()
1922 if name.startswith('test_')
1923 }
1925 # If this is an HTMX request, return only the result HTML
1926 if htmx_partial(request):
1927 if request.GET.get('log'):
1928 # If log=True, render only the log table
1929 return render(request, 'htmx/table.html', context)
1930 response = render(request, 'extras/htmx/script_result.html', context)
1931 if job.completed or not job.started:
1932 response.status_code = 286
1933 return response
1935 return render(request, 'extras/script_result.html', context)
1938#
1939# Markdown
1940#
1942class RenderMarkdownView(LoginRequiredMixin, View):
1944 def post(self, request):
1945 form = forms.RenderMarkdownForm(request.POST)
1946 if not form.is_valid():
1947 HttpResponseBadRequest()
1948 rendered = render_markdown(form.cleaned_data['text'])
1950 return HttpResponse(rendered)