views.py 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. from django.contrib import messages
  2. from django.contrib.auth.mixins import LoginRequiredMixin
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.db.models import Count, Q
  5. from django.http import Http404, HttpResponseForbidden, HttpResponse
  6. from django.shortcuts import get_object_or_404, redirect, render
  7. from django.urls import reverse
  8. from django.views.generic import View
  9. from django_rq.queues import get_connection
  10. from rq import Worker
  11. from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
  12. from extras.dashboard.utils import get_widget_class
  13. from netbox.views import generic
  14. from utilities.forms import ConfirmationForm, get_field_value
  15. from utilities.htmx import is_htmx
  16. from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
  17. from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
  18. from . import filtersets, forms, tables
  19. from .choices import JobResultStatusChoices
  20. from .forms.reports import ReportForm
  21. from .models import *
  22. from .reports import get_report, get_reports, run_report
  23. from .scripts import get_scripts, run_script
  24. #
  25. # Custom fields
  26. #
  27. class CustomFieldListView(generic.ObjectListView):
  28. queryset = CustomField.objects.all()
  29. filterset = filtersets.CustomFieldFilterSet
  30. filterset_form = forms.CustomFieldFilterForm
  31. table = tables.CustomFieldTable
  32. @register_model_view(CustomField)
  33. class CustomFieldView(generic.ObjectView):
  34. queryset = CustomField.objects.all()
  35. @register_model_view(CustomField, 'edit')
  36. class CustomFieldEditView(generic.ObjectEditView):
  37. queryset = CustomField.objects.all()
  38. form = forms.CustomFieldForm
  39. @register_model_view(CustomField, 'delete')
  40. class CustomFieldDeleteView(generic.ObjectDeleteView):
  41. queryset = CustomField.objects.all()
  42. class CustomFieldBulkImportView(generic.BulkImportView):
  43. queryset = CustomField.objects.all()
  44. model_form = forms.CustomFieldImportForm
  45. table = tables.CustomFieldTable
  46. class CustomFieldBulkEditView(generic.BulkEditView):
  47. queryset = CustomField.objects.all()
  48. filterset = filtersets.CustomFieldFilterSet
  49. table = tables.CustomFieldTable
  50. form = forms.CustomFieldBulkEditForm
  51. class CustomFieldBulkDeleteView(generic.BulkDeleteView):
  52. queryset = CustomField.objects.all()
  53. filterset = filtersets.CustomFieldFilterSet
  54. table = tables.CustomFieldTable
  55. #
  56. # Custom links
  57. #
  58. class CustomLinkListView(generic.ObjectListView):
  59. queryset = CustomLink.objects.all()
  60. filterset = filtersets.CustomLinkFilterSet
  61. filterset_form = forms.CustomLinkFilterForm
  62. table = tables.CustomLinkTable
  63. @register_model_view(CustomLink)
  64. class CustomLinkView(generic.ObjectView):
  65. queryset = CustomLink.objects.all()
  66. @register_model_view(CustomLink, 'edit')
  67. class CustomLinkEditView(generic.ObjectEditView):
  68. queryset = CustomLink.objects.all()
  69. form = forms.CustomLinkForm
  70. @register_model_view(CustomLink, 'delete')
  71. class CustomLinkDeleteView(generic.ObjectDeleteView):
  72. queryset = CustomLink.objects.all()
  73. class CustomLinkBulkImportView(generic.BulkImportView):
  74. queryset = CustomLink.objects.all()
  75. model_form = forms.CustomLinkImportForm
  76. table = tables.CustomLinkTable
  77. class CustomLinkBulkEditView(generic.BulkEditView):
  78. queryset = CustomLink.objects.all()
  79. filterset = filtersets.CustomLinkFilterSet
  80. table = tables.CustomLinkTable
  81. form = forms.CustomLinkBulkEditForm
  82. class CustomLinkBulkDeleteView(generic.BulkDeleteView):
  83. queryset = CustomLink.objects.all()
  84. filterset = filtersets.CustomLinkFilterSet
  85. table = tables.CustomLinkTable
  86. #
  87. # Export templates
  88. #
  89. class ExportTemplateListView(generic.ObjectListView):
  90. queryset = ExportTemplate.objects.all()
  91. filterset = filtersets.ExportTemplateFilterSet
  92. filterset_form = forms.ExportTemplateFilterForm
  93. table = tables.ExportTemplateTable
  94. template_name = 'extras/exporttemplate_list.html'
  95. actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
  96. @register_model_view(ExportTemplate)
  97. class ExportTemplateView(generic.ObjectView):
  98. queryset = ExportTemplate.objects.all()
  99. @register_model_view(ExportTemplate, 'edit')
  100. class ExportTemplateEditView(generic.ObjectEditView):
  101. queryset = ExportTemplate.objects.all()
  102. form = forms.ExportTemplateForm
  103. @register_model_view(ExportTemplate, 'delete')
  104. class ExportTemplateDeleteView(generic.ObjectDeleteView):
  105. queryset = ExportTemplate.objects.all()
  106. class ExportTemplateBulkImportView(generic.BulkImportView):
  107. queryset = ExportTemplate.objects.all()
  108. model_form = forms.ExportTemplateImportForm
  109. table = tables.ExportTemplateTable
  110. class ExportTemplateBulkEditView(generic.BulkEditView):
  111. queryset = ExportTemplate.objects.all()
  112. filterset = filtersets.ExportTemplateFilterSet
  113. table = tables.ExportTemplateTable
  114. form = forms.ExportTemplateBulkEditForm
  115. class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
  116. queryset = ExportTemplate.objects.all()
  117. filterset = filtersets.ExportTemplateFilterSet
  118. table = tables.ExportTemplateTable
  119. class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
  120. queryset = ExportTemplate.objects.all()
  121. #
  122. # Saved filters
  123. #
  124. class SavedFilterMixin:
  125. def get_queryset(self, request):
  126. """
  127. Return only shared SavedFilters, or those owned by the current user, unless
  128. this is a superuser.
  129. """
  130. queryset = SavedFilter.objects.all()
  131. user = request.user
  132. if user.is_superuser:
  133. return queryset
  134. if user.is_anonymous:
  135. return queryset.filter(shared=True)
  136. return queryset.filter(
  137. Q(shared=True) | Q(user=user)
  138. )
  139. class SavedFilterListView(SavedFilterMixin, generic.ObjectListView):
  140. filterset = filtersets.SavedFilterFilterSet
  141. filterset_form = forms.SavedFilterFilterForm
  142. table = tables.SavedFilterTable
  143. @register_model_view(SavedFilter)
  144. class SavedFilterView(SavedFilterMixin, generic.ObjectView):
  145. queryset = SavedFilter.objects.all()
  146. @register_model_view(SavedFilter, 'edit')
  147. class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
  148. queryset = SavedFilter.objects.all()
  149. form = forms.SavedFilterForm
  150. def alter_object(self, obj, request, url_args, url_kwargs):
  151. if not obj.pk:
  152. obj.user = request.user
  153. return obj
  154. @register_model_view(SavedFilter, 'delete')
  155. class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
  156. queryset = SavedFilter.objects.all()
  157. class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
  158. queryset = SavedFilter.objects.all()
  159. model_form = forms.SavedFilterImportForm
  160. table = tables.SavedFilterTable
  161. class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
  162. queryset = SavedFilter.objects.all()
  163. filterset = filtersets.SavedFilterFilterSet
  164. table = tables.SavedFilterTable
  165. form = forms.SavedFilterBulkEditForm
  166. class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
  167. queryset = SavedFilter.objects.all()
  168. filterset = filtersets.SavedFilterFilterSet
  169. table = tables.SavedFilterTable
  170. #
  171. # Webhooks
  172. #
  173. class WebhookListView(generic.ObjectListView):
  174. queryset = Webhook.objects.all()
  175. filterset = filtersets.WebhookFilterSet
  176. filterset_form = forms.WebhookFilterForm
  177. table = tables.WebhookTable
  178. @register_model_view(Webhook)
  179. class WebhookView(generic.ObjectView):
  180. queryset = Webhook.objects.all()
  181. @register_model_view(Webhook, 'edit')
  182. class WebhookEditView(generic.ObjectEditView):
  183. queryset = Webhook.objects.all()
  184. form = forms.WebhookForm
  185. @register_model_view(Webhook, 'delete')
  186. class WebhookDeleteView(generic.ObjectDeleteView):
  187. queryset = Webhook.objects.all()
  188. class WebhookBulkImportView(generic.BulkImportView):
  189. queryset = Webhook.objects.all()
  190. model_form = forms.WebhookImportForm
  191. table = tables.WebhookTable
  192. class WebhookBulkEditView(generic.BulkEditView):
  193. queryset = Webhook.objects.all()
  194. filterset = filtersets.WebhookFilterSet
  195. table = tables.WebhookTable
  196. form = forms.WebhookBulkEditForm
  197. class WebhookBulkDeleteView(generic.BulkDeleteView):
  198. queryset = Webhook.objects.all()
  199. filterset = filtersets.WebhookFilterSet
  200. table = tables.WebhookTable
  201. #
  202. # Tags
  203. #
  204. class TagListView(generic.ObjectListView):
  205. queryset = Tag.objects.annotate(
  206. items=count_related(TaggedItem, 'tag')
  207. )
  208. filterset = filtersets.TagFilterSet
  209. filterset_form = forms.TagFilterForm
  210. table = tables.TagTable
  211. @register_model_view(Tag)
  212. class TagView(generic.ObjectView):
  213. queryset = Tag.objects.all()
  214. def get_extra_context(self, request, instance):
  215. tagged_items = TaggedItem.objects.filter(tag=instance)
  216. taggeditem_table = tables.TaggedItemTable(
  217. data=tagged_items,
  218. orderable=False
  219. )
  220. taggeditem_table.configure(request)
  221. object_types = [
  222. {
  223. 'content_type': ContentType.objects.get(pk=ti['content_type']),
  224. 'item_count': ti['item_count']
  225. } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk'))
  226. ]
  227. return {
  228. 'taggeditem_table': taggeditem_table,
  229. 'tagged_item_count': tagged_items.count(),
  230. 'object_types': object_types,
  231. }
  232. @register_model_view(Tag, 'edit')
  233. class TagEditView(generic.ObjectEditView):
  234. queryset = Tag.objects.all()
  235. form = forms.TagForm
  236. @register_model_view(Tag, 'delete')
  237. class TagDeleteView(generic.ObjectDeleteView):
  238. queryset = Tag.objects.all()
  239. class TagBulkImportView(generic.BulkImportView):
  240. queryset = Tag.objects.all()
  241. model_form = forms.TagImportForm
  242. table = tables.TagTable
  243. class TagBulkEditView(generic.BulkEditView):
  244. queryset = Tag.objects.annotate(
  245. items=count_related(TaggedItem, 'tag')
  246. )
  247. table = tables.TagTable
  248. form = forms.TagBulkEditForm
  249. class TagBulkDeleteView(generic.BulkDeleteView):
  250. queryset = Tag.objects.annotate(
  251. items=count_related(TaggedItem, 'tag')
  252. )
  253. table = tables.TagTable
  254. #
  255. # Config contexts
  256. #
  257. class ConfigContextListView(generic.ObjectListView):
  258. queryset = ConfigContext.objects.all()
  259. filterset = filtersets.ConfigContextFilterSet
  260. filterset_form = forms.ConfigContextFilterForm
  261. table = tables.ConfigContextTable
  262. template_name = 'extras/configcontext_list.html'
  263. actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
  264. @register_model_view(ConfigContext)
  265. class ConfigContextView(generic.ObjectView):
  266. queryset = ConfigContext.objects.all()
  267. def get_extra_context(self, request, instance):
  268. # Gather assigned objects for parsing in the template
  269. assigned_objects = (
  270. ('Regions', instance.regions.all),
  271. ('Site Groups', instance.site_groups.all),
  272. ('Sites', instance.sites.all),
  273. ('Locations', instance.locations.all),
  274. ('Device Types', instance.device_types.all),
  275. ('Roles', instance.roles.all),
  276. ('Platforms', instance.platforms.all),
  277. ('Cluster Types', instance.cluster_types.all),
  278. ('Cluster Groups', instance.cluster_groups.all),
  279. ('Clusters', instance.clusters.all),
  280. ('Tenant Groups', instance.tenant_groups.all),
  281. ('Tenants', instance.tenants.all),
  282. ('Tags', instance.tags.all),
  283. )
  284. # Determine user's preferred output format
  285. if request.GET.get('format') in ['json', 'yaml']:
  286. format = request.GET.get('format')
  287. if request.user.is_authenticated:
  288. request.user.config.set('data_format', format, commit=True)
  289. elif request.user.is_authenticated:
  290. format = request.user.config.get('data_format', 'json')
  291. else:
  292. format = 'json'
  293. return {
  294. 'assigned_objects': assigned_objects,
  295. 'format': format,
  296. }
  297. @register_model_view(ConfigContext, 'edit')
  298. class ConfigContextEditView(generic.ObjectEditView):
  299. queryset = ConfigContext.objects.all()
  300. form = forms.ConfigContextForm
  301. class ConfigContextBulkEditView(generic.BulkEditView):
  302. queryset = ConfigContext.objects.all()
  303. filterset = filtersets.ConfigContextFilterSet
  304. table = tables.ConfigContextTable
  305. form = forms.ConfigContextBulkEditForm
  306. @register_model_view(ConfigContext, 'delete')
  307. class ConfigContextDeleteView(generic.ObjectDeleteView):
  308. queryset = ConfigContext.objects.all()
  309. class ConfigContextBulkDeleteView(generic.BulkDeleteView):
  310. queryset = ConfigContext.objects.all()
  311. table = tables.ConfigContextTable
  312. class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
  313. queryset = ConfigContext.objects.all()
  314. class ObjectConfigContextView(generic.ObjectView):
  315. base_template = None
  316. template_name = 'extras/object_configcontext.html'
  317. def get_extra_context(self, request, instance):
  318. source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance)
  319. # Determine user's preferred output format
  320. if request.GET.get('format') in ['json', 'yaml']:
  321. format = request.GET.get('format')
  322. if request.user.is_authenticated:
  323. request.user.config.set('data_format', format, commit=True)
  324. elif request.user.is_authenticated:
  325. format = request.user.config.get('data_format', 'json')
  326. else:
  327. format = 'json'
  328. return {
  329. 'rendered_context': instance.get_config_context(),
  330. 'source_contexts': source_contexts,
  331. 'format': format,
  332. 'base_template': self.base_template,
  333. }
  334. #
  335. # Config templates
  336. #
  337. class ConfigTemplateListView(generic.ObjectListView):
  338. queryset = ConfigTemplate.objects.all()
  339. filterset = filtersets.ConfigTemplateFilterSet
  340. filterset_form = forms.ConfigTemplateFilterForm
  341. table = tables.ConfigTemplateTable
  342. template_name = 'extras/configtemplate_list.html'
  343. actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
  344. @register_model_view(ConfigTemplate)
  345. class ConfigTemplateView(generic.ObjectView):
  346. queryset = ConfigTemplate.objects.all()
  347. @register_model_view(ConfigTemplate, 'edit')
  348. class ConfigTemplateEditView(generic.ObjectEditView):
  349. queryset = ConfigTemplate.objects.all()
  350. form = forms.ConfigTemplateForm
  351. @register_model_view(ConfigTemplate, 'delete')
  352. class ConfigTemplateDeleteView(generic.ObjectDeleteView):
  353. queryset = ConfigTemplate.objects.all()
  354. class ConfigTemplateBulkImportView(generic.BulkImportView):
  355. queryset = ConfigTemplate.objects.all()
  356. model_form = forms.ConfigTemplateImportForm
  357. table = tables.ConfigTemplateTable
  358. class ConfigTemplateBulkEditView(generic.BulkEditView):
  359. queryset = ConfigTemplate.objects.all()
  360. filterset = filtersets.ConfigTemplateFilterSet
  361. table = tables.ConfigTemplateTable
  362. form = forms.ConfigTemplateBulkEditForm
  363. class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
  364. queryset = ConfigTemplate.objects.all()
  365. filterset = filtersets.ConfigTemplateFilterSet
  366. table = tables.ConfigTemplateTable
  367. class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
  368. queryset = ConfigTemplate.objects.all()
  369. #
  370. # Change logging
  371. #
  372. class ObjectChangeListView(generic.ObjectListView):
  373. queryset = ObjectChange.objects.all()
  374. filterset = filtersets.ObjectChangeFilterSet
  375. filterset_form = forms.ObjectChangeFilterForm
  376. table = tables.ObjectChangeTable
  377. template_name = 'extras/objectchange_list.html'
  378. actions = ('export',)
  379. @register_model_view(ObjectChange)
  380. class ObjectChangeView(generic.ObjectView):
  381. queryset = ObjectChange.objects.all()
  382. def get_extra_context(self, request, instance):
  383. related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
  384. request_id=instance.request_id
  385. ).exclude(
  386. pk=instance.pk
  387. )
  388. related_changes_table = tables.ObjectChangeTable(
  389. data=related_changes[:50],
  390. orderable=False
  391. )
  392. objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
  393. changed_object_type=instance.changed_object_type,
  394. changed_object_id=instance.changed_object_id,
  395. )
  396. next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
  397. prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
  398. if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
  399. non_atomic_change = True
  400. prechange_data = prev_change.postchange_data
  401. else:
  402. non_atomic_change = False
  403. prechange_data = instance.prechange_data
  404. if prechange_data and instance.postchange_data:
  405. diff_added = shallow_compare_dict(
  406. prechange_data or dict(),
  407. instance.postchange_data or dict(),
  408. exclude=['last_updated'],
  409. )
  410. diff_removed = {
  411. x: prechange_data.get(x) for x in diff_added
  412. } if prechange_data else {}
  413. else:
  414. diff_added = None
  415. diff_removed = None
  416. return {
  417. 'diff_added': diff_added,
  418. 'diff_removed': diff_removed,
  419. 'next_change': next_change,
  420. 'prev_change': prev_change,
  421. 'related_changes_table': related_changes_table,
  422. 'related_changes_count': related_changes.count(),
  423. 'non_atomic_change': non_atomic_change
  424. }
  425. #
  426. # Image attachments
  427. #
  428. @register_model_view(ImageAttachment, 'edit')
  429. class ImageAttachmentEditView(generic.ObjectEditView):
  430. queryset = ImageAttachment.objects.all()
  431. form = forms.ImageAttachmentForm
  432. template_name = 'extras/imageattachment_edit.html'
  433. def alter_object(self, instance, request, args, kwargs):
  434. if not instance.pk:
  435. # Assign the parent object based on URL kwargs
  436. content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
  437. instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
  438. return instance
  439. def get_return_url(self, request, obj=None):
  440. return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
  441. def get_extra_addanother_params(self, request):
  442. return {
  443. 'content_type': request.GET.get('content_type'),
  444. 'object_id': request.GET.get('object_id'),
  445. }
  446. @register_model_view(ImageAttachment, 'delete')
  447. class ImageAttachmentDeleteView(generic.ObjectDeleteView):
  448. queryset = ImageAttachment.objects.all()
  449. def get_return_url(self, request, obj=None):
  450. return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
  451. #
  452. # Journal entries
  453. #
  454. class JournalEntryListView(generic.ObjectListView):
  455. queryset = JournalEntry.objects.all()
  456. filterset = filtersets.JournalEntryFilterSet
  457. filterset_form = forms.JournalEntryFilterForm
  458. table = tables.JournalEntryTable
  459. actions = ('export', 'bulk_edit', 'bulk_delete')
  460. @register_model_view(JournalEntry)
  461. class JournalEntryView(generic.ObjectView):
  462. queryset = JournalEntry.objects.all()
  463. @register_model_view(JournalEntry, 'edit')
  464. class JournalEntryEditView(generic.ObjectEditView):
  465. queryset = JournalEntry.objects.all()
  466. form = forms.JournalEntryForm
  467. def alter_object(self, obj, request, args, kwargs):
  468. if not obj.pk:
  469. obj.created_by = request.user
  470. return obj
  471. def get_return_url(self, request, instance):
  472. if not instance.assigned_object:
  473. return reverse('extras:journalentry_list')
  474. obj = instance.assigned_object
  475. viewname = get_viewname(obj, 'journal')
  476. return reverse(viewname, kwargs={'pk': obj.pk})
  477. @register_model_view(JournalEntry, 'delete')
  478. class JournalEntryDeleteView(generic.ObjectDeleteView):
  479. queryset = JournalEntry.objects.all()
  480. def get_return_url(self, request, instance):
  481. obj = instance.assigned_object
  482. viewname = get_viewname(obj, 'journal')
  483. return reverse(viewname, kwargs={'pk': obj.pk})
  484. class JournalEntryBulkEditView(generic.BulkEditView):
  485. queryset = JournalEntry.objects.all()
  486. filterset = filtersets.JournalEntryFilterSet
  487. table = tables.JournalEntryTable
  488. form = forms.JournalEntryBulkEditForm
  489. class JournalEntryBulkDeleteView(generic.BulkDeleteView):
  490. queryset = JournalEntry.objects.all()
  491. filterset = filtersets.JournalEntryFilterSet
  492. table = tables.JournalEntryTable
  493. #
  494. # Dashboard widgets
  495. #
  496. class DashboardWidgetAddView(LoginRequiredMixin, View):
  497. template_name = 'extras/dashboard/widget_add.html'
  498. def get(self, request):
  499. if not is_htmx(request):
  500. return redirect('home')
  501. initial = request.GET or {
  502. 'widget_class': 'extras.NoteWidget',
  503. }
  504. widget_form = DashboardWidgetAddForm(initial=initial)
  505. widget_name = get_field_value(widget_form, 'widget_class')
  506. widget_class = get_widget_class(widget_name)
  507. config_form = widget_class.ConfigForm(prefix='config')
  508. return render(request, self.template_name, {
  509. 'widget_class': widget_class,
  510. 'widget_form': widget_form,
  511. 'config_form': config_form,
  512. })
  513. def post(self, request):
  514. widget_form = DashboardWidgetAddForm(request.POST)
  515. config_form = None
  516. widget_class = None
  517. if widget_form.is_valid():
  518. widget_class = get_widget_class(widget_form.cleaned_data['widget_class'])
  519. config_form = widget_class.ConfigForm(request.POST, prefix='config')
  520. if config_form.is_valid():
  521. data = widget_form.cleaned_data
  522. data.pop('widget_class')
  523. data['config'] = config_form.cleaned_data
  524. widget = widget_class(**data)
  525. request.user.dashboard.add_widget(widget)
  526. request.user.dashboard.save()
  527. messages.success(request, f'Added widget {widget.id}')
  528. return HttpResponse(headers={
  529. 'HX-Redirect': reverse('home'),
  530. })
  531. return render(request, self.template_name, {
  532. 'widget_class': widget_class,
  533. 'widget_form': widget_form,
  534. 'config_form': config_form,
  535. })
  536. class DashboardWidgetConfigView(LoginRequiredMixin, View):
  537. template_name = 'extras/dashboard/widget_config.html'
  538. def get(self, request, id):
  539. if not is_htmx(request):
  540. return redirect('home')
  541. widget = request.user.dashboard.get_widget(id)
  542. widget_form = DashboardWidgetForm(initial=widget.form_data)
  543. config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config')
  544. return render(request, self.template_name, {
  545. 'widget_form': widget_form,
  546. 'config_form': config_form,
  547. 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
  548. })
  549. def post(self, request, id):
  550. widget = request.user.dashboard.get_widget(id)
  551. widget_form = DashboardWidgetForm(request.POST)
  552. config_form = widget.ConfigForm(request.POST, prefix='config')
  553. if widget_form.is_valid() and config_form.is_valid():
  554. data = widget_form.cleaned_data
  555. data['config'] = config_form.cleaned_data
  556. request.user.dashboard.config[str(id)].update(data)
  557. request.user.dashboard.save()
  558. messages.success(request, f'Updated widget {widget.id}')
  559. return HttpResponse(headers={
  560. 'HX-Redirect': reverse('home'),
  561. })
  562. return render(request, self.template_name, {
  563. 'widget_form': widget_form,
  564. 'config_form': config_form,
  565. 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
  566. })
  567. class DashboardWidgetDeleteView(LoginRequiredMixin, View):
  568. template_name = 'generic/object_delete.html'
  569. def get(self, request, id):
  570. if not is_htmx(request):
  571. return redirect('home')
  572. widget = request.user.dashboard.get_widget(id)
  573. form = ConfirmationForm(initial=request.GET)
  574. return render(request, 'htmx/delete_form.html', {
  575. 'object_type': widget.__class__.__name__,
  576. 'object': widget,
  577. 'form': form,
  578. 'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id})
  579. })
  580. def post(self, request, id):
  581. form = ConfirmationForm(request.POST)
  582. if form.is_valid():
  583. request.user.dashboard.delete_widget(id)
  584. request.user.dashboard.save()
  585. messages.success(request, f'Deleted widget {id}')
  586. else:
  587. messages.error(request, f'Error deleting widget: {form.errors[0]}')
  588. return redirect(reverse('home'))
  589. #
  590. # Reports
  591. #
  592. class ReportListView(ContentTypePermissionRequiredMixin, View):
  593. """
  594. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
  595. """
  596. def get_required_permission(self):
  597. return 'extras.view_report'
  598. def get(self, request):
  599. reports = get_reports()
  600. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  601. results = {
  602. r.name: r
  603. for r in JobResult.objects.filter(
  604. obj_type=report_content_type,
  605. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  606. ).order_by('name', '-created').distinct('name').defer('data')
  607. }
  608. ret = []
  609. for module, report_list in reports.items():
  610. module_reports = []
  611. for report in report_list.values():
  612. report.result = results.get(report.full_name, None)
  613. module_reports.append(report)
  614. ret.append((module, module_reports))
  615. return render(request, 'extras/report_list.html', {
  616. 'reports': ret,
  617. })
  618. class ReportView(ContentTypePermissionRequiredMixin, View):
  619. """
  620. Display a single Report and its associated JobResult (if any).
  621. """
  622. def get_required_permission(self):
  623. return 'extras.view_report'
  624. def get(self, request, module, name):
  625. report = get_report(module, name)
  626. if report is None:
  627. raise Http404
  628. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  629. report.result = JobResult.objects.filter(
  630. obj_type=report_content_type,
  631. name=report.full_name,
  632. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  633. ).first()
  634. return render(request, 'extras/report.html', {
  635. 'report': report,
  636. 'form': ReportForm(),
  637. })
  638. def post(self, request, module, name):
  639. # Permissions check
  640. if not request.user.has_perm('extras.run_report'):
  641. return HttpResponseForbidden()
  642. report = get_report(module, name)
  643. if report is None:
  644. raise Http404
  645. form = ReportForm(request.POST)
  646. if form.is_valid():
  647. # Allow execution only if RQ worker process is running
  648. if not Worker.count(get_connection('default')):
  649. messages.error(request, "Unable to run report: RQ worker process not running.")
  650. return render(request, 'extras/report.html', {
  651. 'report': report,
  652. })
  653. # Run the Report. A new JobResult is created.
  654. job_result = JobResult.enqueue_job(
  655. run_report,
  656. name=report.full_name,
  657. obj_type=ContentType.objects.get_for_model(Report),
  658. user=request.user,
  659. schedule_at=form.cleaned_data.get('schedule_at'),
  660. interval=form.cleaned_data.get('interval'),
  661. job_timeout=report.job_timeout
  662. )
  663. return redirect('extras:report_result', job_result_pk=job_result.pk)
  664. return render(request, 'extras/report.html', {
  665. 'report': report,
  666. 'form': form,
  667. })
  668. class ReportResultView(ContentTypePermissionRequiredMixin, View):
  669. """
  670. Display a JobResult pertaining to the execution of a Report.
  671. """
  672. def get_required_permission(self):
  673. return 'extras.view_report'
  674. def get(self, request, job_result_pk):
  675. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  676. result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
  677. # Retrieve the Report and attach the JobResult to it
  678. module, report_name = result.name.split('.', maxsplit=1)
  679. report = get_report(module, report_name)
  680. report.result = result
  681. # If this is an HTMX request, return only the result HTML
  682. if is_htmx(request):
  683. response = render(request, 'extras/htmx/report_result.html', {
  684. 'report': report,
  685. 'result': result,
  686. })
  687. if result.completed or not result.started:
  688. response.status_code = 286
  689. return response
  690. return render(request, 'extras/report_result.html', {
  691. 'report': report,
  692. 'result': result,
  693. })
  694. #
  695. # Scripts
  696. #
  697. class GetScriptMixin:
  698. def _get_script(self, name, module=None):
  699. if module is None:
  700. module, name = name.split('.', 1)
  701. scripts = get_scripts()
  702. try:
  703. return scripts[module][name]()
  704. except KeyError:
  705. raise Http404
  706. class ScriptListView(ContentTypePermissionRequiredMixin, View):
  707. def get_required_permission(self):
  708. return 'extras.view_script'
  709. def get(self, request):
  710. scripts = get_scripts(use_names=True)
  711. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  712. results = {
  713. r.name: r
  714. for r in JobResult.objects.filter(
  715. obj_type=script_content_type,
  716. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  717. ).order_by('name', '-created').distinct('name').defer('data')
  718. }
  719. for _scripts in scripts.values():
  720. for script in _scripts.values():
  721. script.result = results.get(script.full_name)
  722. return render(request, 'extras/script_list.html', {
  723. 'scripts': scripts,
  724. })
  725. class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  726. def get_required_permission(self):
  727. return 'extras.view_script'
  728. def get(self, request, module, name):
  729. script = self._get_script(name, module)
  730. form = script.as_form(initial=normalize_querydict(request.GET))
  731. # Look for a pending JobResult (use the latest one by creation timestamp)
  732. script.result = JobResult.objects.filter(
  733. obj_type=ContentType.objects.get_for_model(Script),
  734. name=script.full_name,
  735. ).exclude(
  736. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  737. ).first()
  738. return render(request, 'extras/script.html', {
  739. 'module': module,
  740. 'script': script,
  741. 'form': form,
  742. })
  743. def post(self, request, module, name):
  744. # Permissions check
  745. if not request.user.has_perm('extras.run_script'):
  746. return HttpResponseForbidden()
  747. script = self._get_script(name, module)
  748. form = script.as_form(request.POST, request.FILES)
  749. # Allow execution only if RQ worker process is running
  750. if not Worker.count(get_connection('default')):
  751. messages.error(request, "Unable to run script: RQ worker process not running.")
  752. elif form.is_valid():
  753. job_result = JobResult.enqueue_job(
  754. run_script,
  755. name=script.full_name,
  756. obj_type=ContentType.objects.get_for_model(Script),
  757. user=request.user,
  758. schedule_at=form.cleaned_data.pop('_schedule_at'),
  759. interval=form.cleaned_data.pop('_interval'),
  760. data=form.cleaned_data,
  761. request=copy_safe_request(request),
  762. job_timeout=script.job_timeout,
  763. commit=form.cleaned_data.pop('_commit')
  764. )
  765. return redirect('extras:script_result', job_result_pk=job_result.pk)
  766. return render(request, 'extras/script.html', {
  767. 'module': module,
  768. 'script': script,
  769. 'form': form,
  770. })
  771. class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  772. def get_required_permission(self):
  773. return 'extras.view_script'
  774. def get(self, request, job_result_pk):
  775. result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
  776. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  777. if result.obj_type != script_content_type:
  778. raise Http404
  779. script = self._get_script(result.name)
  780. # If this is an HTMX request, return only the result HTML
  781. if is_htmx(request):
  782. response = render(request, 'extras/htmx/script_result.html', {
  783. 'script': script,
  784. 'result': result,
  785. })
  786. if result.completed or not result.started:
  787. response.status_code = 286
  788. return response
  789. return render(request, 'extras/script_result.html', {
  790. 'script': script,
  791. 'result': result,
  792. 'class_name': script.__class__.__name__
  793. })
  794. #
  795. # Job results
  796. #
  797. class JobResultListView(generic.ObjectListView):
  798. queryset = JobResult.objects.all()
  799. filterset = filtersets.JobResultFilterSet
  800. filterset_form = forms.JobResultFilterForm
  801. table = tables.JobResultTable
  802. actions = ('export', 'delete', 'bulk_delete', )
  803. class JobResultDeleteView(generic.ObjectDeleteView):
  804. queryset = JobResult.objects.all()
  805. class JobResultBulkDeleteView(generic.BulkDeleteView):
  806. queryset = JobResult.objects.all()
  807. filterset = filtersets.JobResultFilterSet
  808. table = tables.JobResultTable