views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  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 not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
  158. non_atomic_change = True
  159. prechange_data = prev_change.postchange_data
  160. else:
  161. non_atomic_change = False
  162. prechange_data = instance.prechange_data
  163. if prechange_data and instance.postchange_data:
  164. diff_added = shallow_compare_dict(
  165. prechange_data or dict(),
  166. instance.postchange_data or dict(),
  167. exclude=['last_updated'],
  168. )
  169. diff_removed = {
  170. x: prechange_data.get(x) for x in diff_added
  171. } if prechange_data else {}
  172. else:
  173. diff_added = None
  174. diff_removed = None
  175. return {
  176. 'diff_added': diff_added,
  177. 'diff_removed': diff_removed,
  178. 'next_change': next_change,
  179. 'prev_change': prev_change,
  180. 'related_changes_table': related_changes_table,
  181. 'related_changes_count': related_changes.count(),
  182. 'non_atomic_change': non_atomic_change
  183. }
  184. class ObjectChangeLogView(View):
  185. """
  186. Present a history of changes made to a particular object.
  187. base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
  188. """
  189. base_template = None
  190. def get(self, request, model, **kwargs):
  191. # Handle QuerySet restriction of parent object if needed
  192. if hasattr(model.objects, 'restrict'):
  193. obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
  194. else:
  195. obj = get_object_or_404(model, **kwargs)
  196. # Gather all changes for this object (and its related objects)
  197. content_type = ContentType.objects.get_for_model(model)
  198. objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
  199. 'user', 'changed_object_type'
  200. ).filter(
  201. Q(changed_object_type=content_type, changed_object_id=obj.pk) |
  202. Q(related_object_type=content_type, related_object_id=obj.pk)
  203. )
  204. objectchanges_table = tables.ObjectChangeTable(
  205. data=objectchanges,
  206. orderable=False
  207. )
  208. paginate_table(objectchanges_table, request)
  209. # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
  210. # fall back to using base.html.
  211. if self.base_template is None:
  212. self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
  213. return render(request, 'extras/object_changelog.html', {
  214. 'object': obj,
  215. 'table': objectchanges_table,
  216. 'base_template': self.base_template,
  217. 'active_tab': 'changelog',
  218. })
  219. #
  220. # Image attachments
  221. #
  222. class ImageAttachmentEditView(generic.ObjectEditView):
  223. queryset = ImageAttachment.objects.all()
  224. model_form = forms.ImageAttachmentForm
  225. def alter_obj(self, imageattachment, request, args, kwargs):
  226. if not imageattachment.pk:
  227. # Assign the parent object based on URL kwargs
  228. model = kwargs.get('model')
  229. imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
  230. return imageattachment
  231. def get_return_url(self, request, imageattachment):
  232. return imageattachment.parent.get_absolute_url()
  233. class ImageAttachmentDeleteView(generic.ObjectDeleteView):
  234. queryset = ImageAttachment.objects.all()
  235. def get_return_url(self, request, imageattachment):
  236. return imageattachment.parent.get_absolute_url()
  237. #
  238. # Journal entries
  239. #
  240. class JournalEntryListView(generic.ObjectListView):
  241. queryset = JournalEntry.objects.all()
  242. filterset = filtersets.JournalEntryFilterSet
  243. filterset_form = forms.JournalEntryFilterForm
  244. table = tables.JournalEntryTable
  245. action_buttons = ('export',)
  246. class JournalEntryView(generic.ObjectView):
  247. queryset = JournalEntry.objects.all()
  248. class JournalEntryEditView(generic.ObjectEditView):
  249. queryset = JournalEntry.objects.all()
  250. model_form = forms.JournalEntryForm
  251. def alter_obj(self, obj, request, args, kwargs):
  252. if not obj.pk:
  253. obj.created_by = request.user
  254. return obj
  255. def get_return_url(self, request, instance):
  256. if not instance.assigned_object:
  257. return reverse('extras:journalentry_list')
  258. obj = instance.assigned_object
  259. viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
  260. return reverse(viewname, kwargs={'pk': obj.pk})
  261. class JournalEntryDeleteView(generic.ObjectDeleteView):
  262. queryset = JournalEntry.objects.all()
  263. def get_return_url(self, request, instance):
  264. obj = instance.assigned_object
  265. viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
  266. return reverse(viewname, kwargs={'pk': obj.pk})
  267. class JournalEntryBulkEditView(generic.BulkEditView):
  268. queryset = JournalEntry.objects.prefetch_related('created_by')
  269. filterset = filtersets.JournalEntryFilterSet
  270. table = tables.JournalEntryTable
  271. form = forms.JournalEntryBulkEditForm
  272. class JournalEntryBulkDeleteView(generic.BulkDeleteView):
  273. queryset = JournalEntry.objects.prefetch_related('created_by')
  274. filterset = filtersets.JournalEntryFilterSet
  275. table = tables.JournalEntryTable
  276. class ObjectJournalView(View):
  277. """
  278. Show all journal entries for an object.
  279. base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
  280. """
  281. base_template = None
  282. def get(self, request, model, **kwargs):
  283. # Handle QuerySet restriction of parent object if needed
  284. if hasattr(model.objects, 'restrict'):
  285. obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
  286. else:
  287. obj = get_object_or_404(model, **kwargs)
  288. # Gather all changes for this object (and its related objects)
  289. content_type = ContentType.objects.get_for_model(model)
  290. journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
  291. assigned_object_type=content_type,
  292. assigned_object_id=obj.pk
  293. )
  294. journalentry_table = tables.ObjectJournalTable(journalentries)
  295. paginate_table(journalentry_table, request)
  296. if request.user.has_perm('extras.add_journalentry'):
  297. form = forms.JournalEntryForm(
  298. initial={
  299. 'assigned_object_type': ContentType.objects.get_for_model(obj),
  300. 'assigned_object_id': obj.pk
  301. }
  302. )
  303. else:
  304. form = None
  305. # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
  306. # fall back to using base.html.
  307. if self.base_template is None:
  308. self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
  309. return render(request, 'extras/object_journal.html', {
  310. 'object': obj,
  311. 'form': form,
  312. 'table': journalentry_table,
  313. 'base_template': self.base_template,
  314. 'active_tab': 'journal',
  315. })
  316. #
  317. # Reports
  318. #
  319. class ReportListView(ContentTypePermissionRequiredMixin, View):
  320. """
  321. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
  322. """
  323. def get_required_permission(self):
  324. return 'extras.view_report'
  325. def get(self, request):
  326. reports = get_reports()
  327. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  328. results = {
  329. r.name: r
  330. for r in JobResult.objects.filter(
  331. obj_type=report_content_type,
  332. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  333. ).defer('data')
  334. }
  335. ret = []
  336. for module, report_list in reports:
  337. module_reports = []
  338. for report in report_list:
  339. report.result = results.get(report.full_name, None)
  340. module_reports.append(report)
  341. ret.append((module, module_reports))
  342. return render(request, 'extras/report_list.html', {
  343. 'reports': ret,
  344. })
  345. class ReportView(ContentTypePermissionRequiredMixin, View):
  346. """
  347. Display a single Report and its associated JobResult (if any).
  348. """
  349. def get_required_permission(self):
  350. return 'extras.view_report'
  351. def get(self, request, module, name):
  352. report = get_report(module, name)
  353. if report is None:
  354. raise Http404
  355. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  356. report.result = JobResult.objects.filter(
  357. obj_type=report_content_type,
  358. name=report.full_name,
  359. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  360. ).first()
  361. return render(request, 'extras/report.html', {
  362. 'report': report,
  363. 'run_form': ConfirmationForm(),
  364. })
  365. def post(self, request, module, name):
  366. # Permissions check
  367. if not request.user.has_perm('extras.run_report'):
  368. return HttpResponseForbidden()
  369. report = get_report(module, name)
  370. if report is None:
  371. raise Http404
  372. # Allow execution only if RQ worker process is running
  373. if not Worker.count(get_connection('default')):
  374. messages.error(request, "Unable to run report: RQ worker process not running.")
  375. return render(request, 'extras/report.html', {
  376. 'report': report,
  377. })
  378. # Run the Report. A new JobResult is created.
  379. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  380. job_result = JobResult.enqueue_job(
  381. run_report,
  382. report.full_name,
  383. report_content_type,
  384. request.user
  385. )
  386. return redirect('extras:report_result', job_result_pk=job_result.pk)
  387. class ReportResultView(ContentTypePermissionRequiredMixin, View):
  388. """
  389. Display a JobResult pertaining to the execution of a Report.
  390. """
  391. def get_required_permission(self):
  392. return 'extras.view_report'
  393. def get(self, request, job_result_pk):
  394. report_content_type = ContentType.objects.get(app_label='extras', model='report')
  395. jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
  396. # Retrieve the Report and attach the JobResult to it
  397. module, report_name = jobresult.name.split('.')
  398. report = get_report(module, report_name)
  399. report.result = jobresult
  400. return render(request, 'extras/report_result.html', {
  401. 'report': report,
  402. 'result': jobresult,
  403. })
  404. #
  405. # Scripts
  406. #
  407. class GetScriptMixin:
  408. def _get_script(self, name, module=None):
  409. if module is None:
  410. module, name = name.split('.', 1)
  411. scripts = get_scripts()
  412. try:
  413. return scripts[module][name]()
  414. except KeyError:
  415. raise Http404
  416. class ScriptListView(ContentTypePermissionRequiredMixin, View):
  417. def get_required_permission(self):
  418. return 'extras.view_script'
  419. def get(self, request):
  420. scripts = get_scripts(use_names=True)
  421. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  422. results = {
  423. r.name: r
  424. for r in JobResult.objects.filter(
  425. obj_type=script_content_type,
  426. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  427. ).defer('data')
  428. }
  429. for _scripts in scripts.values():
  430. for script in _scripts.values():
  431. script.result = results.get(script.full_name)
  432. return render(request, 'extras/script_list.html', {
  433. 'scripts': scripts,
  434. })
  435. class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  436. def get_required_permission(self):
  437. return 'extras.view_script'
  438. def get(self, request, module, name):
  439. script = self._get_script(name, module)
  440. form = script.as_form(initial=request.GET)
  441. # Look for a pending JobResult (use the latest one by creation timestamp)
  442. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  443. script.result = JobResult.objects.filter(
  444. obj_type=script_content_type,
  445. name=script.full_name,
  446. ).exclude(
  447. status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
  448. ).first()
  449. return render(request, 'extras/script.html', {
  450. 'module': module,
  451. 'script': script,
  452. 'form': form,
  453. })
  454. def post(self, request, module, name):
  455. # Permissions check
  456. if not request.user.has_perm('extras.run_script'):
  457. return HttpResponseForbidden()
  458. script = self._get_script(name, module)
  459. form = script.as_form(request.POST, request.FILES)
  460. # Allow execution only if RQ worker process is running
  461. if not Worker.count(get_connection('default')):
  462. messages.error(request, "Unable to run script: RQ worker process not running.")
  463. elif form.is_valid():
  464. commit = form.cleaned_data.pop('_commit')
  465. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  466. job_result = JobResult.enqueue_job(
  467. run_script,
  468. script.full_name,
  469. script_content_type,
  470. request.user,
  471. data=form.cleaned_data,
  472. request=copy_safe_request(request),
  473. commit=commit
  474. )
  475. return redirect('extras:script_result', job_result_pk=job_result.pk)
  476. return render(request, 'extras/script.html', {
  477. 'module': module,
  478. 'script': script,
  479. 'form': form,
  480. })
  481. class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
  482. def get_required_permission(self):
  483. return 'extras.view_script'
  484. def get(self, request, job_result_pk):
  485. result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
  486. script_content_type = ContentType.objects.get(app_label='extras', model='script')
  487. if result.obj_type != script_content_type:
  488. raise Http404
  489. script = self._get_script(result.name)
  490. return render(request, 'extras/script_result.html', {
  491. 'script': script,
  492. 'result': result,
  493. 'class_name': script.__class__.__name__
  494. })