views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. from django.contrib import messages
  2. from django.contrib.contenttypes.models import ContentType
  3. from django.db.models import Count, Q
  4. from django.http import Http404, HttpResponseForbidden
  5. from django.shortcuts import get_object_or_404, redirect, render
  6. from django.urls import reverse
  7. from django.views.generic import View
  8. from django_rq.queues import get_connection
  9. from rq import Worker
  10. from netbox.views import generic
  11. from utilities.forms import ConfirmationForm
  12. from utilities.tables import paginate_table
  13. from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
  14. from utilities.views import ContentTypePermissionRequiredMixin
  15. from . import filtersets, forms, tables
  16. from .choices import JobResultStatusChoices
  17. from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
  18. from .reports import get_report, get_reports, run_report
  19. from .scripts import get_scripts, run_script
  20. #
  21. # Tags
  22. #
  23. class TagListView(generic.ObjectListView):
  24. queryset = Tag.objects.annotate(
  25. items=count_related(TaggedItem, 'tag')
  26. )
  27. filterset = filtersets.TagFilterSet
  28. filterset_form = forms.TagFilterForm
  29. table = tables.TagTable
  30. class TagView(generic.ObjectView):
  31. queryset = Tag.objects.all()
  32. def get_extra_context(self, request, instance):
  33. tagged_items = TaggedItem.objects.filter(tag=instance)
  34. taggeditem_table = tables.TaggedItemTable(
  35. data=tagged_items,
  36. orderable=False
  37. )
  38. paginate_table(taggeditem_table, request)
  39. object_types = [
  40. {
  41. 'content_type': ContentType.objects.get(pk=ti['content_type']),
  42. 'item_count': ti['item_count']
  43. } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk'))
  44. ]
  45. return {
  46. 'taggeditem_table': taggeditem_table,
  47. 'tagged_item_count': tagged_items.count(),
  48. 'object_types': object_types,
  49. }
  50. class TagEditView(generic.ObjectEditView):
  51. queryset = Tag.objects.all()
  52. model_form = forms.TagForm
  53. class TagDeleteView(generic.ObjectDeleteView):
  54. queryset = Tag.objects.all()
  55. class TagBulkImportView(generic.BulkImportView):
  56. queryset = Tag.objects.all()
  57. model_form = forms.TagCSVForm
  58. table = tables.TagTable
  59. class TagBulkEditView(generic.BulkEditView):
  60. queryset = Tag.objects.annotate(
  61. items=count_related(TaggedItem, 'tag')
  62. )
  63. table = tables.TagTable
  64. form = forms.TagBulkEditForm
  65. class TagBulkDeleteView(generic.BulkDeleteView):
  66. queryset = Tag.objects.annotate(
  67. items=count_related(TaggedItem, 'tag')
  68. )
  69. table = tables.TagTable
  70. #
  71. # Config contexts
  72. #
  73. class ConfigContextListView(generic.ObjectListView):
  74. queryset = ConfigContext.objects.all()
  75. filterset = filtersets.ConfigContextFilterSet
  76. filterset_form = forms.ConfigContextFilterForm
  77. table = tables.ConfigContextTable
  78. action_buttons = ('add',)
  79. class ConfigContextView(generic.ObjectView):
  80. queryset = ConfigContext.objects.all()
  81. def get_extra_context(self, request, instance):
  82. # Determine user's preferred output format
  83. if request.GET.get('format') in ['json', 'yaml']:
  84. format = request.GET.get('format')
  85. if request.user.is_authenticated:
  86. request.user.config.set('extras.configcontext.format', format, commit=True)
  87. elif request.user.is_authenticated:
  88. format = request.user.config.get('extras.configcontext.format', 'json')
  89. else:
  90. format = 'json'
  91. return {
  92. 'format': format,
  93. }
  94. class ConfigContextEditView(generic.ObjectEditView):
  95. queryset = ConfigContext.objects.all()
  96. model_form = forms.ConfigContextForm
  97. template_name = 'extras/configcontext_edit.html'
  98. class ConfigContextBulkEditView(generic.BulkEditView):
  99. queryset = ConfigContext.objects.all()
  100. filterset = filtersets.ConfigContextFilterSet
  101. table = tables.ConfigContextTable
  102. form = forms.ConfigContextBulkEditForm
  103. class ConfigContextDeleteView(generic.ObjectDeleteView):
  104. queryset = ConfigContext.objects.all()
  105. class ConfigContextBulkDeleteView(generic.BulkDeleteView):
  106. queryset = ConfigContext.objects.all()
  107. table = tables.ConfigContextTable
  108. class ObjectConfigContextView(generic.ObjectView):
  109. base_template = None
  110. template_name = 'extras/object_configcontext.html'
  111. def get_extra_context(self, request, instance):
  112. source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance)
  113. # Determine user's preferred output format
  114. if request.GET.get('format') in ['json', 'yaml']:
  115. format = request.GET.get('format')
  116. if request.user.is_authenticated:
  117. request.user.config.set('extras.configcontext.format', format, commit=True)
  118. elif request.user.is_authenticated:
  119. format = request.user.config.get('extras.configcontext.format', 'json')
  120. else:
  121. format = 'json'
  122. return {
  123. 'rendered_context': instance.get_config_context(),
  124. 'source_contexts': source_contexts,
  125. 'format': format,
  126. 'base_template': self.base_template,
  127. 'active_tab': 'config-context',
  128. }
  129. #
  130. # Change logging
  131. #
  132. class ObjectChangeListView(generic.ObjectListView):
  133. queryset = ObjectChange.objects.all()
  134. filterset = filtersets.ObjectChangeFilterSet
  135. filterset_form = forms.ObjectChangeFilterForm
  136. table = tables.ObjectChangeTable
  137. template_name = 'extras/objectchange_list.html'
  138. action_buttons = ('export',)
  139. class ObjectChangeView(generic.ObjectView):
  140. queryset = ObjectChange.objects.all()
  141. def get_extra_context(self, request, instance):
  142. related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
  143. request_id=instance.request_id
  144. ).exclude(
  145. pk=instance.pk
  146. )
  147. related_changes_table = tables.ObjectChangeTable(
  148. data=related_changes[:50],
  149. orderable=False
  150. )
  151. objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
  152. changed_object_type=instance.changed_object_type,
  153. changed_object_id=instance.changed_object_id,
  154. )
  155. next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
  156. prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
  157. if instance.prechange_data and instance.postchange_data:
  158. diff_added = shallow_compare_dict(
  159. instance.prechange_data or dict(),
  160. instance.postchange_data or dict(),
  161. exclude=['last_updated'],
  162. )
  163. diff_removed = {
  164. x: instance.prechange_data.get(x) for x in diff_added
  165. } if instance.prechange_data else {}
  166. else:
  167. diff_added = None
  168. diff_removed = None
  169. return {
  170. 'diff_added': diff_added,
  171. 'diff_removed': diff_removed,
  172. 'next_change': next_change,
  173. 'prev_change': prev_change,
  174. 'related_changes_table': related_changes_table,
  175. 'related_changes_count': related_changes.count()
  176. }
  177. class ObjectChangeLogView(View):
  178. """
  179. Present a history of changes made to a particular object.
  180. base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
  181. """
  182. base_template = None
  183. def get(self, request, model, **kwargs):
  184. # Handle QuerySet restriction of parent object if needed
  185. if hasattr(model.objects, 'restrict'):
  186. obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
  187. else:
  188. obj = get_object_or_404(model, **kwargs)
  189. # Gather all changes for this object (and its related objects)
  190. content_type = ContentType.objects.get_for_model(model)
  191. objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
  192. 'user', 'changed_object_type'
  193. ).filter(
  194. Q(changed_object_type=content_type, changed_object_id=obj.pk) |
  195. Q(related_object_type=content_type, related_object_id=obj.pk)
  196. )
  197. objectchanges_table = tables.ObjectChangeTable(
  198. data=objectchanges,
  199. orderable=False
  200. )
  201. paginate_table(objectchanges_table, request)
  202. # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
  203. # fall back to using base.html.
  204. if self.base_template is None:
  205. self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
  206. return render(request, 'extras/object_changelog.html', {
  207. 'object': obj,
  208. 'table': objectchanges_table,
  209. 'base_template': self.base_template,
  210. 'active_tab': 'changelog',
  211. })
  212. #
  213. # Image attachments
  214. #
  215. class ImageAttachmentEditView(generic.ObjectEditView):
  216. queryset = ImageAttachment.objects.all()
  217. model_form = forms.ImageAttachmentForm
  218. def alter_obj(self, imageattachment, request, args, kwargs):
  219. if not imageattachment.pk:
  220. # Assign the parent object based on URL kwargs
  221. model = kwargs.get('model')
  222. imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
  223. return imageattachment
  224. def get_return_url(self, request, imageattachment):
  225. return imageattachment.parent.get_absolute_url()
  226. class ImageAttachmentDeleteView(generic.ObjectDeleteView):
  227. queryset = ImageAttachment.objects.all()
  228. def get_return_url(self, request, imageattachment):
  229. return imageattachment.parent.get_absolute_url()
  230. #
  231. # Journal entries
  232. #
  233. class JournalEntryListView(generic.ObjectListView):
  234. queryset = JournalEntry.objects.all()
  235. filterset = filtersets.JournalEntryFilterSet
  236. filterset_form = forms.JournalEntryFilterForm
  237. table = tables.JournalEntryTable
  238. action_buttons = ('export',)
  239. class JournalEntryView(generic.ObjectView):
  240. queryset = JournalEntry.objects.all()
  241. class JournalEntryEditView(generic.ObjectEditView):
  242. queryset = JournalEntry.objects.all()
  243. model_form = forms.JournalEntryForm
  244. def alter_obj(self, obj, request, args, kwargs):
  245. if not obj.pk:
  246. obj.created_by = request.user
  247. return obj
  248. def get_return_url(self, request, instance):
  249. if not instance.assigned_object:
  250. return reverse('extras:journalentry_list')
  251. obj = instance.assigned_object
  252. viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
  253. return reverse(viewname, kwargs={'pk': obj.pk})
  254. class JournalEntryDeleteView(generic.ObjectDeleteView):
  255. queryset = JournalEntry.objects.all()
  256. def get_return_url(self, request, instance):
  257. obj = instance.assigned_object
  258. viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
  259. return reverse(viewname, kwargs={'pk': obj.pk})
  260. class JournalEntryBulkEditView(generic.BulkEditView):
  261. queryset = JournalEntry.objects.prefetch_related('created_by')
  262. filterset = filtersets.JournalEntryFilterSet
  263. table = tables.JournalEntryTable
  264. form = forms.JournalEntryBulkEditForm
  265. class JournalEntryBulkDeleteView(generic.BulkDeleteView):
  266. queryset = JournalEntry.objects.prefetch_related('created_by')
  267. filterset = filtersets.JournalEntryFilterSet
  268. table = tables.JournalEntryTable
  269. class ObjectJournalView(View):
  270. """
  271. Show all journal entries for an object.
  272. base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
  273. """
  274. base_template = None
  275. def get(self, request, model, **kwargs):
  276. # Handle QuerySet restriction of parent object if needed
  277. if hasattr(model.objects, 'restrict'):
  278. obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
  279. else:
  280. obj = get_object_or_404(model, **kwargs)
  281. # Gather all changes for this object (and its related objects)
  282. content_type = ContentType.objects.get_for_model(model)
  283. journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
  284. assigned_object_type=content_type,
  285. assigned_object_id=obj.pk
  286. )
  287. journalentry_table = tables.ObjectJournalTable(journalentries)
  288. paginate_table(journalentry_table, request)
  289. if request.user.has_perm('extras.add_journalentry'):
  290. form = forms.JournalEntryForm(
  291. initial={
  292. 'assigned_object_type': ContentType.objects.get_for_model(obj),
  293. 'assigned_object_id': obj.pk
  294. }
  295. )
  296. else:
  297. form = None
  298. # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
  299. # fall back to using base.html.
  300. if self.base_template is None:
  301. self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
  302. return render(request, 'extras/object_journal.html', {
  303. 'object': obj,
  304. 'form': form,
  305. 'table': journalentry_table,
  306. 'base_template': self.base_template,
  307. 'active_tab': 'journal',
  308. })
  309. #
  310. # Reports
  311. #
  312. class ReportListView(ContentTypePermissionRequiredMixin, View):
  313. """
  314. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
  315. """
  316. def get_required_permission(self):
  317. return 'extras.view_report'
  318. def get(self, request):
  319. reports = get_reports()
  320. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  321. results = {
  322. r.name: r
  323. for r in JobResult.objects.filter(
  324. obj_type=report_content_type,
  325. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  326. ).defer('data')
  327. }
  328. ret = []
  329. for module, report_list in reports:
  330. module_reports = []
  331. for report in report_list:
  332. report.result = results.get(report.full_name, None)
  333. module_reports.append(report)
  334. ret.append((module, module_reports))
  335. return render(request, 'extras/report_list.html', {
  336. 'reports': ret,
  337. })
  338. class ReportView(ContentTypePermissionRequiredMixin, View):
  339. """
  340. Display a single Report and its associated JobResult (if any).
  341. """
  342. def get_required_permission(self):
  343. return 'extras.view_report'
  344. def get(self, request, module, name):
  345. report = get_report(module, name)
  346. if report is None:
  347. raise Http404
  348. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  349. report.result = JobResult.objects.filter(
  350. obj_type=report_content_type,
  351. name=report.full_name,
  352. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  353. ).first()
  354. return render(request, 'extras/report.html', {
  355. 'report': report,
  356. 'run_form': ConfirmationForm(),
  357. })
  358. def post(self, request, module, name):
  359. # Permissions check
  360. if not request.user.has_perm('extras.run_report'):
  361. return HttpResponseForbidden()
  362. report = get_report(module, name)
  363. if report is None:
  364. raise Http404
  365. # Allow execution only if RQ worker process is running
  366. if not Worker.count(get_connection('default')):
  367. messages.error(request, "Unable to run report: RQ worker process not running.")
  368. return render(request, 'extras/report.html', {
  369. 'report': report,
  370. })
  371. # Run the Report. A new JobResult is created.
  372. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  373. job_result = JobResult.enqueue_job(
  374. run_report,
  375. report.full_name,
  376. report_content_type,
  377. request.user
  378. )
  379. return redirect('extras:report_result', job_result_pk=job_result.pk)
  380. class ReportResultView(ContentTypePermissionRequiredMixin, View):
  381. """
  382. Display a JobResult pertaining to the execution of a Report.
  383. """
  384. def get_required_permission(self):
  385. return 'extras.view_report'
  386. def get(self, request, job_result_pk):
  387. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  388. jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
  389. # Retrieve the Report and attach the JobResult to it
  390. module, report_name = jobresult.name.split('.')
  391. report = get_report(module, report_name)
  392. report.result = jobresult
  393. return render(request, 'extras/report_result.html', {
  394. 'report': report,
  395. 'result': jobresult,
  396. })
  397. #
  398. # Scripts
  399. #
  400. class GetScriptMixin:
  401. def _get_script(self, name, module=None):
  402. if module is None:
  403. module, name = name.split('.', 1)
  404. scripts = get_scripts()
  405. try:
  406. return scripts[module][name]()
  407. except KeyError:
  408. raise Http404
  409. class ScriptListView(ContentTypePermissionRequiredMixin, View):
  410. def get_required_permission(self):
  411. return 'extras.view_script'
  412. def get(self, request):
  413. scripts = get_scripts(use_names=True)
  414. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  415. results = {
  416. r.name: r
  417. for r in JobResult.objects.filter(
  418. obj_type=script_content_type,
  419. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  420. ).defer('data')
  421. }
  422. for _scripts in scripts.values():
  423. for script in _scripts.values():
  424. script.result = results.get(script.full_name)
  425. return render(request, 'extras/script_list.html', {
  426. 'scripts': scripts,
  427. })
  428. class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  429. def get_required_permission(self):
  430. return 'extras.view_script'
  431. def get(self, request, module, name):
  432. script = self._get_script(name, module)
  433. form = script.as_form(initial=request.GET)
  434. # Look for a pending JobResult (use the latest one by creation timestamp)
  435. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  436. script.result = JobResult.objects.filter(
  437. obj_type=script_content_type,
  438. name=script.full_name,
  439. ).exclude(
  440. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  441. ).first()
  442. return render(request, 'extras/script.html', {
  443. 'module': module,
  444. 'script': script,
  445. 'form': form,
  446. })
  447. def post(self, request, module, name):
  448. # Permissions check
  449. if not request.user.has_perm('extras.run_script'):
  450. return HttpResponseForbidden()
  451. script = self._get_script(name, module)
  452. form = script.as_form(request.POST, request.FILES)
  453. # Allow execution only if RQ worker process is running
  454. if not Worker.count(get_connection('default')):
  455. messages.error(request, "Unable to run script: RQ worker process not running.")
  456. elif form.is_valid():
  457. commit = form.cleaned_data.pop('_commit')
  458. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  459. job_result = JobResult.enqueue_job(
  460. run_script,
  461. script.full_name,
  462. script_content_type,
  463. request.user,
  464. data=form.cleaned_data,
  465. request=copy_safe_request(request),
  466. commit=commit
  467. )
  468. return redirect('extras:script_result', job_result_pk=job_result.pk)
  469. return render(request, 'extras/script.html', {
  470. 'module': module,
  471. 'script': script,
  472. 'form': form,
  473. })
  474. class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  475. def get_required_permission(self):
  476. return 'extras.view_script'
  477. def get(self, request, job_result_pk):
  478. result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
  479. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  480. if result.obj_type != script_content_type:
  481. raise Http404
  482. script = self._get_script(result.name)
  483. return render(request, 'extras/script_result.html', {
  484. 'script': script,
  485. 'result': result,
  486. 'class_name': script.__class__.__name__
  487. })