views.py 20 KB

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