views.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281
  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.core.paginator import EmptyPage
  5. from django.db.models import Count, Q
  6. from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
  7. from django.shortcuts import get_object_or_404, redirect, render
  8. from django.urls import reverse
  9. from django.utils.translation import gettext as _
  10. from django.views.generic import View
  11. from core.choices import ManagedFileRootPathChoices
  12. from core.forms import ManagedFileForm
  13. from core.models import Job
  14. from core.tables import JobTable
  15. from dcim.models import Device, DeviceRole, Platform
  16. from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
  17. from extras.dashboard.utils import get_widget_class
  18. from netbox.constants import DEFAULT_ACTION_PERMISSIONS
  19. from netbox.views import generic
  20. from netbox.views.generic.mixins import TableMixin
  21. from utilities.data import shallow_compare_dict
  22. from utilities.forms import ConfirmationForm, get_field_value
  23. from utilities.htmx import htmx_partial
  24. from utilities.paginator import EnhancedPaginator, get_paginate_count
  25. from utilities.query import count_related
  26. from utilities.querydict import normalize_querydict
  27. from utilities.request import copy_safe_request
  28. from utilities.rqworker import get_workers_for_queue
  29. from utilities.templatetags.builtins.filters import render_markdown
  30. from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
  31. from virtualization.models import VirtualMachine
  32. from . import filtersets, forms, tables
  33. from .models import *
  34. from .scripts import run_script
  35. from .tables import ReportResultsTable, ScriptResultsTable
  36. #
  37. # Custom fields
  38. #
  39. class CustomFieldListView(generic.ObjectListView):
  40. queryset = CustomField.objects.select_related('choice_set')
  41. filterset = filtersets.CustomFieldFilterSet
  42. filterset_form = forms.CustomFieldFilterForm
  43. table = tables.CustomFieldTable
  44. @register_model_view(CustomField)
  45. class CustomFieldView(generic.ObjectView):
  46. queryset = CustomField.objects.select_related('choice_set')
  47. def get_extra_context(self, request, instance):
  48. related_models = ()
  49. for object_type in instance.object_types.all():
  50. related_models += (
  51. object_type.model_class().objects.restrict(request.user, 'view').exclude(
  52. Q(**{f'custom_field_data__{instance.name}': ''}) |
  53. Q(**{f'custom_field_data__{instance.name}': None})
  54. ),
  55. )
  56. return {
  57. 'related_models': related_models
  58. }
  59. @register_model_view(CustomField, 'edit')
  60. class CustomFieldEditView(generic.ObjectEditView):
  61. queryset = CustomField.objects.select_related('choice_set')
  62. form = forms.CustomFieldForm
  63. @register_model_view(CustomField, 'delete')
  64. class CustomFieldDeleteView(generic.ObjectDeleteView):
  65. queryset = CustomField.objects.select_related('choice_set')
  66. class CustomFieldBulkImportView(generic.BulkImportView):
  67. queryset = CustomField.objects.select_related('choice_set')
  68. model_form = forms.CustomFieldImportForm
  69. class CustomFieldBulkEditView(generic.BulkEditView):
  70. queryset = CustomField.objects.select_related('choice_set')
  71. filterset = filtersets.CustomFieldFilterSet
  72. table = tables.CustomFieldTable
  73. form = forms.CustomFieldBulkEditForm
  74. class CustomFieldBulkDeleteView(generic.BulkDeleteView):
  75. queryset = CustomField.objects.select_related('choice_set')
  76. filterset = filtersets.CustomFieldFilterSet
  77. table = tables.CustomFieldTable
  78. #
  79. # Custom field choices
  80. #
  81. class CustomFieldChoiceSetListView(generic.ObjectListView):
  82. queryset = CustomFieldChoiceSet.objects.all()
  83. filterset = filtersets.CustomFieldChoiceSetFilterSet
  84. filterset_form = forms.CustomFieldChoiceSetFilterForm
  85. table = tables.CustomFieldChoiceSetTable
  86. @register_model_view(CustomFieldChoiceSet)
  87. class CustomFieldChoiceSetView(generic.ObjectView):
  88. queryset = CustomFieldChoiceSet.objects.all()
  89. def get_extra_context(self, request, instance):
  90. # Paginate choices list
  91. per_page = get_paginate_count(request)
  92. try:
  93. page_number = request.GET.get('page', 1)
  94. except ValueError:
  95. page_number = 1
  96. paginator = EnhancedPaginator(instance.choices, per_page)
  97. try:
  98. choices = paginator.page(page_number)
  99. except EmptyPage:
  100. choices = paginator.page(paginator.num_pages)
  101. return {
  102. 'paginator': paginator,
  103. 'choices': choices,
  104. }
  105. @register_model_view(CustomFieldChoiceSet, 'edit')
  106. class CustomFieldChoiceSetEditView(generic.ObjectEditView):
  107. queryset = CustomFieldChoiceSet.objects.all()
  108. form = forms.CustomFieldChoiceSetForm
  109. @register_model_view(CustomFieldChoiceSet, 'delete')
  110. class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
  111. queryset = CustomFieldChoiceSet.objects.all()
  112. class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
  113. queryset = CustomFieldChoiceSet.objects.all()
  114. model_form = forms.CustomFieldChoiceSetImportForm
  115. class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
  116. queryset = CustomFieldChoiceSet.objects.all()
  117. filterset = filtersets.CustomFieldChoiceSetFilterSet
  118. table = tables.CustomFieldChoiceSetTable
  119. form = forms.CustomFieldChoiceSetBulkEditForm
  120. class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
  121. queryset = CustomFieldChoiceSet.objects.all()
  122. filterset = filtersets.CustomFieldChoiceSetFilterSet
  123. table = tables.CustomFieldChoiceSetTable
  124. #
  125. # Custom links
  126. #
  127. class CustomLinkListView(generic.ObjectListView):
  128. queryset = CustomLink.objects.all()
  129. filterset = filtersets.CustomLinkFilterSet
  130. filterset_form = forms.CustomLinkFilterForm
  131. table = tables.CustomLinkTable
  132. @register_model_view(CustomLink)
  133. class CustomLinkView(generic.ObjectView):
  134. queryset = CustomLink.objects.all()
  135. @register_model_view(CustomLink, 'edit')
  136. class CustomLinkEditView(generic.ObjectEditView):
  137. queryset = CustomLink.objects.all()
  138. form = forms.CustomLinkForm
  139. @register_model_view(CustomLink, 'delete')
  140. class CustomLinkDeleteView(generic.ObjectDeleteView):
  141. queryset = CustomLink.objects.all()
  142. class CustomLinkBulkImportView(generic.BulkImportView):
  143. queryset = CustomLink.objects.all()
  144. model_form = forms.CustomLinkImportForm
  145. class CustomLinkBulkEditView(generic.BulkEditView):
  146. queryset = CustomLink.objects.all()
  147. filterset = filtersets.CustomLinkFilterSet
  148. table = tables.CustomLinkTable
  149. form = forms.CustomLinkBulkEditForm
  150. class CustomLinkBulkDeleteView(generic.BulkDeleteView):
  151. queryset = CustomLink.objects.all()
  152. filterset = filtersets.CustomLinkFilterSet
  153. table = tables.CustomLinkTable
  154. #
  155. # Export templates
  156. #
  157. class ExportTemplateListView(generic.ObjectListView):
  158. queryset = ExportTemplate.objects.all()
  159. filterset = filtersets.ExportTemplateFilterSet
  160. filterset_form = forms.ExportTemplateFilterForm
  161. table = tables.ExportTemplateTable
  162. template_name = 'extras/exporttemplate_list.html'
  163. actions = {
  164. **DEFAULT_ACTION_PERMISSIONS,
  165. 'bulk_sync': {'sync'},
  166. }
  167. @register_model_view(ExportTemplate)
  168. class ExportTemplateView(generic.ObjectView):
  169. queryset = ExportTemplate.objects.all()
  170. @register_model_view(ExportTemplate, 'edit')
  171. class ExportTemplateEditView(generic.ObjectEditView):
  172. queryset = ExportTemplate.objects.all()
  173. form = forms.ExportTemplateForm
  174. @register_model_view(ExportTemplate, 'delete')
  175. class ExportTemplateDeleteView(generic.ObjectDeleteView):
  176. queryset = ExportTemplate.objects.all()
  177. class ExportTemplateBulkImportView(generic.BulkImportView):
  178. queryset = ExportTemplate.objects.all()
  179. model_form = forms.ExportTemplateImportForm
  180. class ExportTemplateBulkEditView(generic.BulkEditView):
  181. queryset = ExportTemplate.objects.all()
  182. filterset = filtersets.ExportTemplateFilterSet
  183. table = tables.ExportTemplateTable
  184. form = forms.ExportTemplateBulkEditForm
  185. class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
  186. queryset = ExportTemplate.objects.all()
  187. filterset = filtersets.ExportTemplateFilterSet
  188. table = tables.ExportTemplateTable
  189. class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
  190. queryset = ExportTemplate.objects.all()
  191. #
  192. # Saved filters
  193. #
  194. class SavedFilterMixin:
  195. def get_queryset(self, request):
  196. """
  197. Return only shared SavedFilters, or those owned by the current user, unless
  198. this is a superuser.
  199. """
  200. queryset = SavedFilter.objects.all()
  201. user = request.user
  202. if user.is_superuser:
  203. return queryset
  204. if user.is_anonymous:
  205. return queryset.filter(shared=True)
  206. return queryset.filter(
  207. Q(shared=True) | Q(user=user)
  208. )
  209. class SavedFilterListView(SavedFilterMixin, generic.ObjectListView):
  210. filterset = filtersets.SavedFilterFilterSet
  211. filterset_form = forms.SavedFilterFilterForm
  212. table = tables.SavedFilterTable
  213. @register_model_view(SavedFilter)
  214. class SavedFilterView(SavedFilterMixin, generic.ObjectView):
  215. queryset = SavedFilter.objects.all()
  216. @register_model_view(SavedFilter, 'edit')
  217. class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
  218. queryset = SavedFilter.objects.all()
  219. form = forms.SavedFilterForm
  220. def alter_object(self, obj, request, url_args, url_kwargs):
  221. if not obj.pk:
  222. obj.user = request.user
  223. return obj
  224. @register_model_view(SavedFilter, 'delete')
  225. class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
  226. queryset = SavedFilter.objects.all()
  227. class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
  228. queryset = SavedFilter.objects.all()
  229. model_form = forms.SavedFilterImportForm
  230. class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
  231. queryset = SavedFilter.objects.all()
  232. filterset = filtersets.SavedFilterFilterSet
  233. table = tables.SavedFilterTable
  234. form = forms.SavedFilterBulkEditForm
  235. class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
  236. queryset = SavedFilter.objects.all()
  237. filterset = filtersets.SavedFilterFilterSet
  238. table = tables.SavedFilterTable
  239. #
  240. # Bookmarks
  241. #
  242. class BookmarkCreateView(generic.ObjectEditView):
  243. form = forms.BookmarkForm
  244. def get_queryset(self, request):
  245. return Bookmark.objects.filter(user=request.user)
  246. def alter_object(self, obj, request, url_args, url_kwargs):
  247. obj.user = request.user
  248. return obj
  249. @register_model_view(Bookmark, 'delete')
  250. class BookmarkDeleteView(generic.ObjectDeleteView):
  251. def get_queryset(self, request):
  252. return Bookmark.objects.filter(user=request.user)
  253. class BookmarkBulkDeleteView(generic.BulkDeleteView):
  254. table = tables.BookmarkTable
  255. def get_queryset(self, request):
  256. return Bookmark.objects.filter(user=request.user)
  257. #
  258. # Webhooks
  259. #
  260. class WebhookListView(generic.ObjectListView):
  261. queryset = Webhook.objects.all()
  262. filterset = filtersets.WebhookFilterSet
  263. filterset_form = forms.WebhookFilterForm
  264. table = tables.WebhookTable
  265. @register_model_view(Webhook)
  266. class WebhookView(generic.ObjectView):
  267. queryset = Webhook.objects.all()
  268. @register_model_view(Webhook, 'edit')
  269. class WebhookEditView(generic.ObjectEditView):
  270. queryset = Webhook.objects.all()
  271. form = forms.WebhookForm
  272. @register_model_view(Webhook, 'delete')
  273. class WebhookDeleteView(generic.ObjectDeleteView):
  274. queryset = Webhook.objects.all()
  275. class WebhookBulkImportView(generic.BulkImportView):
  276. queryset = Webhook.objects.all()
  277. model_form = forms.WebhookImportForm
  278. class WebhookBulkEditView(generic.BulkEditView):
  279. queryset = Webhook.objects.all()
  280. filterset = filtersets.WebhookFilterSet
  281. table = tables.WebhookTable
  282. form = forms.WebhookBulkEditForm
  283. class WebhookBulkDeleteView(generic.BulkDeleteView):
  284. queryset = Webhook.objects.all()
  285. filterset = filtersets.WebhookFilterSet
  286. table = tables.WebhookTable
  287. #
  288. # Event Rules
  289. #
  290. class EventRuleListView(generic.ObjectListView):
  291. queryset = EventRule.objects.all()
  292. filterset = filtersets.EventRuleFilterSet
  293. filterset_form = forms.EventRuleFilterForm
  294. table = tables.EventRuleTable
  295. @register_model_view(EventRule)
  296. class EventRuleView(generic.ObjectView):
  297. queryset = EventRule.objects.all()
  298. @register_model_view(EventRule, 'edit')
  299. class EventRuleEditView(generic.ObjectEditView):
  300. queryset = EventRule.objects.all()
  301. form = forms.EventRuleForm
  302. @register_model_view(EventRule, 'delete')
  303. class EventRuleDeleteView(generic.ObjectDeleteView):
  304. queryset = EventRule.objects.all()
  305. class EventRuleBulkImportView(generic.BulkImportView):
  306. queryset = EventRule.objects.all()
  307. model_form = forms.EventRuleImportForm
  308. class EventRuleBulkEditView(generic.BulkEditView):
  309. queryset = EventRule.objects.all()
  310. filterset = filtersets.EventRuleFilterSet
  311. table = tables.EventRuleTable
  312. form = forms.EventRuleBulkEditForm
  313. class EventRuleBulkDeleteView(generic.BulkDeleteView):
  314. queryset = EventRule.objects.all()
  315. filterset = filtersets.EventRuleFilterSet
  316. table = tables.EventRuleTable
  317. #
  318. # Tags
  319. #
  320. class TagListView(generic.ObjectListView):
  321. queryset = Tag.objects.annotate(
  322. items=count_related(TaggedItem, 'tag')
  323. )
  324. filterset = filtersets.TagFilterSet
  325. filterset_form = forms.TagFilterForm
  326. table = tables.TagTable
  327. @register_model_view(Tag)
  328. class TagView(generic.ObjectView):
  329. queryset = Tag.objects.all()
  330. def get_extra_context(self, request, instance):
  331. tagged_items = TaggedItem.objects.filter(tag=instance)
  332. taggeditem_table = tables.TaggedItemTable(
  333. data=tagged_items,
  334. orderable=False
  335. )
  336. taggeditem_table.configure(request)
  337. object_types = [
  338. {
  339. 'content_type': ContentType.objects.get(pk=ti['content_type']),
  340. 'item_count': ti['item_count']
  341. } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk'))
  342. ]
  343. return {
  344. 'taggeditem_table': taggeditem_table,
  345. 'tagged_item_count': tagged_items.count(),
  346. 'object_types': object_types,
  347. }
  348. @register_model_view(Tag, 'edit')
  349. class TagEditView(generic.ObjectEditView):
  350. queryset = Tag.objects.all()
  351. form = forms.TagForm
  352. @register_model_view(Tag, 'delete')
  353. class TagDeleteView(generic.ObjectDeleteView):
  354. queryset = Tag.objects.all()
  355. class TagBulkImportView(generic.BulkImportView):
  356. queryset = Tag.objects.all()
  357. model_form = forms.TagImportForm
  358. class TagBulkEditView(generic.BulkEditView):
  359. queryset = Tag.objects.annotate(
  360. items=count_related(TaggedItem, 'tag')
  361. )
  362. table = tables.TagTable
  363. form = forms.TagBulkEditForm
  364. class TagBulkDeleteView(generic.BulkDeleteView):
  365. queryset = Tag.objects.annotate(
  366. items=count_related(TaggedItem, 'tag')
  367. )
  368. table = tables.TagTable
  369. #
  370. # Config contexts
  371. #
  372. class ConfigContextListView(generic.ObjectListView):
  373. queryset = ConfigContext.objects.all()
  374. filterset = filtersets.ConfigContextFilterSet
  375. filterset_form = forms.ConfigContextFilterForm
  376. table = tables.ConfigContextTable
  377. template_name = 'extras/configcontext_list.html'
  378. actions = {
  379. 'add': {'add'},
  380. 'bulk_edit': {'change'},
  381. 'bulk_delete': {'delete'},
  382. 'bulk_sync': {'sync'},
  383. }
  384. @register_model_view(ConfigContext)
  385. class ConfigContextView(generic.ObjectView):
  386. queryset = ConfigContext.objects.all()
  387. def get_extra_context(self, request, instance):
  388. # Gather assigned objects for parsing in the template
  389. assigned_objects = (
  390. ('Regions', instance.regions.all),
  391. ('Site Groups', instance.site_groups.all),
  392. ('Sites', instance.sites.all),
  393. ('Locations', instance.locations.all),
  394. ('Device Types', instance.device_types.all),
  395. ('Roles', instance.roles.all),
  396. ('Platforms', instance.platforms.all),
  397. ('Cluster Types', instance.cluster_types.all),
  398. ('Cluster Groups', instance.cluster_groups.all),
  399. ('Clusters', instance.clusters.all),
  400. ('Tenant Groups', instance.tenant_groups.all),
  401. ('Tenants', instance.tenants.all),
  402. ('Tags', instance.tags.all),
  403. )
  404. # Determine user's preferred output format
  405. if request.GET.get('format') in ['json', 'yaml']:
  406. format = request.GET.get('format')
  407. if request.user.is_authenticated:
  408. request.user.config.set('data_format', format, commit=True)
  409. elif request.user.is_authenticated:
  410. format = request.user.config.get('data_format', 'json')
  411. else:
  412. format = 'json'
  413. return {
  414. 'assigned_objects': assigned_objects,
  415. 'format': format,
  416. }
  417. @register_model_view(ConfigContext, 'edit')
  418. class ConfigContextEditView(generic.ObjectEditView):
  419. queryset = ConfigContext.objects.all()
  420. form = forms.ConfigContextForm
  421. class ConfigContextBulkEditView(generic.BulkEditView):
  422. queryset = ConfigContext.objects.all()
  423. filterset = filtersets.ConfigContextFilterSet
  424. table = tables.ConfigContextTable
  425. form = forms.ConfigContextBulkEditForm
  426. @register_model_view(ConfigContext, 'delete')
  427. class ConfigContextDeleteView(generic.ObjectDeleteView):
  428. queryset = ConfigContext.objects.all()
  429. class ConfigContextBulkDeleteView(generic.BulkDeleteView):
  430. queryset = ConfigContext.objects.all()
  431. filterset = filtersets.ConfigContextFilterSet
  432. table = tables.ConfigContextTable
  433. class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
  434. queryset = ConfigContext.objects.all()
  435. class ObjectConfigContextView(generic.ObjectView):
  436. base_template = None
  437. template_name = 'extras/object_configcontext.html'
  438. def get_extra_context(self, request, instance):
  439. source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance)
  440. # Determine user's preferred output format
  441. if request.GET.get('format') in ['json', 'yaml']:
  442. format = request.GET.get('format')
  443. if request.user.is_authenticated:
  444. request.user.config.set('data_format', format, commit=True)
  445. elif request.user.is_authenticated:
  446. format = request.user.config.get('data_format', 'json')
  447. else:
  448. format = 'json'
  449. return {
  450. 'rendered_context': instance.get_config_context(),
  451. 'source_contexts': source_contexts,
  452. 'format': format,
  453. 'base_template': self.base_template,
  454. }
  455. #
  456. # Config templates
  457. #
  458. class ConfigTemplateListView(generic.ObjectListView):
  459. queryset = ConfigTemplate.objects.annotate(
  460. device_count=count_related(Device, 'config_template'),
  461. vm_count=count_related(VirtualMachine, 'config_template'),
  462. role_count=count_related(DeviceRole, 'config_template'),
  463. platform_count=count_related(Platform, 'config_template'),
  464. )
  465. filterset = filtersets.ConfigTemplateFilterSet
  466. filterset_form = forms.ConfigTemplateFilterForm
  467. table = tables.ConfigTemplateTable
  468. template_name = 'extras/configtemplate_list.html'
  469. actions = {
  470. **DEFAULT_ACTION_PERMISSIONS,
  471. 'bulk_sync': {'sync'},
  472. }
  473. @register_model_view(ConfigTemplate)
  474. class ConfigTemplateView(generic.ObjectView):
  475. queryset = ConfigTemplate.objects.all()
  476. @register_model_view(ConfigTemplate, 'edit')
  477. class ConfigTemplateEditView(generic.ObjectEditView):
  478. queryset = ConfigTemplate.objects.all()
  479. form = forms.ConfigTemplateForm
  480. @register_model_view(ConfigTemplate, 'delete')
  481. class ConfigTemplateDeleteView(generic.ObjectDeleteView):
  482. queryset = ConfigTemplate.objects.all()
  483. class ConfigTemplateBulkImportView(generic.BulkImportView):
  484. queryset = ConfigTemplate.objects.all()
  485. model_form = forms.ConfigTemplateImportForm
  486. class ConfigTemplateBulkEditView(generic.BulkEditView):
  487. queryset = ConfigTemplate.objects.all()
  488. filterset = filtersets.ConfigTemplateFilterSet
  489. table = tables.ConfigTemplateTable
  490. form = forms.ConfigTemplateBulkEditForm
  491. class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
  492. queryset = ConfigTemplate.objects.all()
  493. filterset = filtersets.ConfigTemplateFilterSet
  494. table = tables.ConfigTemplateTable
  495. class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
  496. queryset = ConfigTemplate.objects.all()
  497. #
  498. # Change logging
  499. #
  500. class ObjectChangeListView(generic.ObjectListView):
  501. queryset = ObjectChange.objects.valid_models()
  502. filterset = filtersets.ObjectChangeFilterSet
  503. filterset_form = forms.ObjectChangeFilterForm
  504. table = tables.ObjectChangeTable
  505. template_name = 'extras/objectchange_list.html'
  506. actions = {
  507. 'export': {'view'},
  508. }
  509. @register_model_view(ObjectChange)
  510. class ObjectChangeView(generic.ObjectView):
  511. queryset = ObjectChange.objects.valid_models()
  512. def get_extra_context(self, request, instance):
  513. related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  514. request_id=instance.request_id
  515. ).exclude(
  516. pk=instance.pk
  517. )
  518. related_changes_table = tables.ObjectChangeTable(
  519. data=related_changes[:50],
  520. orderable=False
  521. )
  522. objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  523. changed_object_type=instance.changed_object_type,
  524. changed_object_id=instance.changed_object_id,
  525. )
  526. next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
  527. prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
  528. if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
  529. non_atomic_change = True
  530. prechange_data = prev_change.postchange_data_clean
  531. else:
  532. non_atomic_change = False
  533. prechange_data = instance.prechange_data_clean
  534. if prechange_data and instance.postchange_data:
  535. diff_added = shallow_compare_dict(
  536. prechange_data or dict(),
  537. instance.postchange_data_clean or dict(),
  538. exclude=['last_updated'],
  539. )
  540. diff_removed = {
  541. x: prechange_data.get(x) for x in diff_added
  542. } if prechange_data else {}
  543. else:
  544. diff_added = None
  545. diff_removed = None
  546. return {
  547. 'diff_added': diff_added,
  548. 'diff_removed': diff_removed,
  549. 'next_change': next_change,
  550. 'prev_change': prev_change,
  551. 'related_changes_table': related_changes_table,
  552. 'related_changes_count': related_changes.count(),
  553. 'non_atomic_change': non_atomic_change
  554. }
  555. #
  556. # Image attachments
  557. #
  558. class ImageAttachmentListView(generic.ObjectListView):
  559. queryset = ImageAttachment.objects.all()
  560. filterset = filtersets.ImageAttachmentFilterSet
  561. filterset_form = forms.ImageAttachmentFilterForm
  562. table = tables.ImageAttachmentTable
  563. actions = {
  564. 'export': {'view'},
  565. }
  566. @register_model_view(ImageAttachment, 'edit')
  567. class ImageAttachmentEditView(generic.ObjectEditView):
  568. queryset = ImageAttachment.objects.all()
  569. form = forms.ImageAttachmentForm
  570. def alter_object(self, instance, request, args, kwargs):
  571. if not instance.pk:
  572. # Assign the parent object based on URL kwargs
  573. object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type'))
  574. instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
  575. return instance
  576. def get_return_url(self, request, obj=None):
  577. return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
  578. def get_extra_addanother_params(self, request):
  579. return {
  580. 'object_type': request.GET.get('object_type'),
  581. 'object_id': request.GET.get('object_id'),
  582. }
  583. @register_model_view(ImageAttachment, 'delete')
  584. class ImageAttachmentDeleteView(generic.ObjectDeleteView):
  585. queryset = ImageAttachment.objects.all()
  586. def get_return_url(self, request, obj=None):
  587. return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
  588. #
  589. # Journal entries
  590. #
  591. class JournalEntryListView(generic.ObjectListView):
  592. queryset = JournalEntry.objects.all()
  593. filterset = filtersets.JournalEntryFilterSet
  594. filterset_form = forms.JournalEntryFilterForm
  595. table = tables.JournalEntryTable
  596. actions = {
  597. 'import': {'add'},
  598. 'export': {'view'},
  599. 'bulk_edit': {'change'},
  600. 'bulk_delete': {'delete'},
  601. }
  602. @register_model_view(JournalEntry)
  603. class JournalEntryView(generic.ObjectView):
  604. queryset = JournalEntry.objects.all()
  605. @register_model_view(JournalEntry, 'edit')
  606. class JournalEntryEditView(generic.ObjectEditView):
  607. queryset = JournalEntry.objects.all()
  608. form = forms.JournalEntryForm
  609. def alter_object(self, obj, request, args, kwargs):
  610. if not obj.pk:
  611. obj.created_by = request.user
  612. return obj
  613. def get_return_url(self, request, instance):
  614. if not instance.assigned_object:
  615. return reverse('extras:journalentry_list')
  616. obj = instance.assigned_object
  617. viewname = get_viewname(obj, 'journal')
  618. return reverse(viewname, kwargs={'pk': obj.pk})
  619. @register_model_view(JournalEntry, 'delete')
  620. class JournalEntryDeleteView(generic.ObjectDeleteView):
  621. queryset = JournalEntry.objects.all()
  622. def get_return_url(self, request, instance):
  623. obj = instance.assigned_object
  624. viewname = get_viewname(obj, 'journal')
  625. return reverse(viewname, kwargs={'pk': obj.pk})
  626. class JournalEntryBulkEditView(generic.BulkEditView):
  627. queryset = JournalEntry.objects.all()
  628. filterset = filtersets.JournalEntryFilterSet
  629. table = tables.JournalEntryTable
  630. form = forms.JournalEntryBulkEditForm
  631. class JournalEntryBulkDeleteView(generic.BulkDeleteView):
  632. queryset = JournalEntry.objects.all()
  633. filterset = filtersets.JournalEntryFilterSet
  634. table = tables.JournalEntryTable
  635. class JournalEntryBulkImportView(generic.BulkImportView):
  636. queryset = JournalEntry.objects.all()
  637. model_form = forms.JournalEntryImportForm
  638. #
  639. # Dashboard & widgets
  640. #
  641. class DashboardResetView(LoginRequiredMixin, View):
  642. template_name = 'extras/dashboard/reset.html'
  643. def get(self, request):
  644. get_object_or_404(Dashboard.objects.all(), user=request.user)
  645. form = ConfirmationForm()
  646. return render(request, self.template_name, {
  647. 'form': form,
  648. 'return_url': reverse('home'),
  649. })
  650. def post(self, request):
  651. dashboard = get_object_or_404(Dashboard.objects.all(), user=request.user)
  652. form = ConfirmationForm(request.POST)
  653. if form.is_valid():
  654. dashboard.delete()
  655. messages.success(request, _("Your dashboard has been reset."))
  656. return redirect(reverse('home'))
  657. return render(request, self.template_name, {
  658. 'form': form,
  659. 'return_url': reverse('home'),
  660. })
  661. class DashboardWidgetAddView(LoginRequiredMixin, View):
  662. template_name = 'extras/dashboard/widget_add.html'
  663. def get(self, request):
  664. if not request.htmx:
  665. return redirect('home')
  666. initial = request.GET or {
  667. 'widget_class': 'extras.NoteWidget',
  668. }
  669. widget_form = DashboardWidgetAddForm(initial=initial)
  670. widget_name = get_field_value(widget_form, 'widget_class')
  671. widget_class = get_widget_class(widget_name)
  672. config_form = widget_class.ConfigForm(initial=widget_class.default_config, prefix='config')
  673. return render(request, self.template_name, {
  674. 'widget_class': widget_class,
  675. 'widget_form': widget_form,
  676. 'config_form': config_form,
  677. })
  678. def post(self, request):
  679. widget_form = DashboardWidgetAddForm(request.POST)
  680. config_form = None
  681. widget_class = None
  682. if widget_form.is_valid():
  683. widget_class = get_widget_class(widget_form.cleaned_data['widget_class'])
  684. config_form = widget_class.ConfigForm(request.POST, prefix='config')
  685. if config_form.is_valid():
  686. data = widget_form.cleaned_data
  687. data.pop('widget_class')
  688. data['config'] = config_form.cleaned_data
  689. widget = widget_class(**data)
  690. request.user.dashboard.add_widget(widget)
  691. request.user.dashboard.save()
  692. messages.success(request, _('Added widget: ') + str(widget.id))
  693. return HttpResponse(headers={
  694. 'HX-Redirect': reverse('home'),
  695. })
  696. return render(request, self.template_name, {
  697. 'widget_class': widget_class,
  698. 'widget_form': widget_form,
  699. 'config_form': config_form,
  700. })
  701. class DashboardWidgetConfigView(LoginRequiredMixin, View):
  702. template_name = 'extras/dashboard/widget_config.html'
  703. def get(self, request, id):
  704. if not request.htmx:
  705. return redirect('home')
  706. widget = request.user.dashboard.get_widget(id)
  707. widget_form = DashboardWidgetForm(initial=widget.form_data)
  708. config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config')
  709. return render(request, self.template_name, {
  710. 'widget_class': widget.__class__,
  711. 'widget_form': widget_form,
  712. 'config_form': config_form,
  713. 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
  714. })
  715. def post(self, request, id):
  716. widget = request.user.dashboard.get_widget(id)
  717. widget_form = DashboardWidgetForm(request.POST)
  718. config_form = widget.ConfigForm(request.POST, prefix='config')
  719. if widget_form.is_valid() and config_form.is_valid():
  720. data = widget_form.cleaned_data
  721. data['config'] = config_form.cleaned_data
  722. request.user.dashboard.config[str(id)].update(data)
  723. request.user.dashboard.save()
  724. messages.success(request, _('Updated widget: ') + str(widget.id))
  725. return HttpResponse(headers={
  726. 'HX-Redirect': reverse('home'),
  727. })
  728. return render(request, self.template_name, {
  729. 'widget_form': widget_form,
  730. 'config_form': config_form,
  731. 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
  732. })
  733. class DashboardWidgetDeleteView(LoginRequiredMixin, View):
  734. template_name = 'generic/object_delete.html'
  735. def get(self, request, id):
  736. if not request.htmx:
  737. return redirect('home')
  738. widget = request.user.dashboard.get_widget(id)
  739. form = ConfirmationForm(initial=request.GET)
  740. return render(request, 'htmx/delete_form.html', {
  741. 'object_type': widget.__class__.__name__,
  742. 'object': widget,
  743. 'form': form,
  744. 'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id})
  745. })
  746. def post(self, request, id):
  747. form = ConfirmationForm(request.POST)
  748. if form.is_valid():
  749. request.user.dashboard.delete_widget(id)
  750. request.user.dashboard.save()
  751. messages.success(request, _('Deleted widget: ') + str(id))
  752. else:
  753. messages.error(request, _('Error deleting widget: ') + str(form.errors[0]))
  754. return redirect(reverse('home'))
  755. #
  756. # Scripts
  757. #
  758. @register_model_view(ScriptModule, 'edit')
  759. class ScriptModuleCreateView(generic.ObjectEditView):
  760. queryset = ScriptModule.objects.all()
  761. form = ManagedFileForm
  762. def alter_object(self, obj, *args, **kwargs):
  763. obj.file_root = ManagedFileRootPathChoices.SCRIPTS
  764. return obj
  765. @register_model_view(ScriptModule, 'delete')
  766. class ScriptModuleDeleteView(generic.ObjectDeleteView):
  767. queryset = ScriptModule.objects.all()
  768. default_return_url = 'extras:script_list'
  769. class ScriptListView(ContentTypePermissionRequiredMixin, View):
  770. def get_required_permission(self):
  771. return 'extras.view_script'
  772. def get(self, request):
  773. script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
  774. 'data_source', 'data_file', 'jobs'
  775. )
  776. return render(request, 'extras/script_list.html', {
  777. 'model': ScriptModule,
  778. 'script_modules': script_modules,
  779. })
  780. class BaseScriptView(generic.ObjectView):
  781. queryset = Script.objects.all()
  782. def _get_script_class(self, script):
  783. """
  784. Return an instance of the Script's Python class
  785. """
  786. if script_class := script.python_class:
  787. return script_class()
  788. class ScriptView(BaseScriptView):
  789. def get(self, request, **kwargs):
  790. script = self.get_object(**kwargs)
  791. script_class = self._get_script_class(script)
  792. if not script_class:
  793. return render(request, 'extras/script.html', {
  794. 'script': script,
  795. })
  796. form = script_class.as_form(initial=normalize_querydict(request.GET))
  797. return render(request, 'extras/script.html', {
  798. 'script': script,
  799. 'script_class': script_class,
  800. 'form': form,
  801. 'job_count': script.jobs.count(),
  802. })
  803. def post(self, request, **kwargs):
  804. script = self.get_object(**kwargs)
  805. if not request.user.has_perm('extras.run_script', obj=script):
  806. return HttpResponseForbidden()
  807. script_class = self._get_script_class(script)
  808. if not script_class:
  809. return render(request, 'extras/script.html', {
  810. 'script': script,
  811. })
  812. form = script_class.as_form(request.POST, request.FILES)
  813. # Allow execution only if RQ worker process is running
  814. if not get_workers_for_queue('default'):
  815. messages.error(request, _("Unable to run script: RQ worker process not running."))
  816. elif form.is_valid():
  817. job = Job.enqueue(
  818. run_script,
  819. instance=script,
  820. name=script_class.class_name,
  821. user=request.user,
  822. schedule_at=form.cleaned_data.pop('_schedule_at'),
  823. interval=form.cleaned_data.pop('_interval'),
  824. data=form.cleaned_data,
  825. request=copy_safe_request(request),
  826. job_timeout=script.python_class.job_timeout,
  827. commit=form.cleaned_data.pop('_commit')
  828. )
  829. return redirect('extras:script_result', job_pk=job.pk)
  830. return render(request, 'extras/script.html', {
  831. 'script': script,
  832. 'script_class': script.python_class(),
  833. 'form': form,
  834. 'job_count': script.jobs.count(),
  835. })
  836. class ScriptSourceView(BaseScriptView):
  837. queryset = Script.objects.all()
  838. def get(self, request, **kwargs):
  839. script = self.get_object(**kwargs)
  840. script_class = self._get_script_class(script)
  841. return render(request, 'extras/script/source.html', {
  842. 'script': script,
  843. 'script_class': script_class,
  844. 'job_count': script.jobs.count(),
  845. 'tab': 'source',
  846. })
  847. class ScriptJobsView(BaseScriptView):
  848. queryset = Script.objects.all()
  849. def get(self, request, **kwargs):
  850. script = self.get_object(**kwargs)
  851. jobs_table = JobTable(
  852. data=script.jobs.all(),
  853. orderable=False,
  854. user=request.user
  855. )
  856. jobs_table.configure(request)
  857. return render(request, 'extras/script/jobs.html', {
  858. 'script': script,
  859. 'table': jobs_table,
  860. 'job_count': script.jobs.count(),
  861. 'tab': 'jobs',
  862. })
  863. class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
  864. """
  865. Redirect legacy (pre-v4.0) script URLs. Examples:
  866. /extras/scripts/<module>/<name>/ --> /extras/scripts/<id>/
  867. /extras/scripts/<module>/<name>/source/ --> /extras/scripts/<id>/source/
  868. /extras/scripts/<module>/<name>/jobs/ --> /extras/scripts/<id>/jobs/
  869. """
  870. def get_required_permission(self):
  871. return 'extras.view_script'
  872. def get(self, request, module, name, path=''):
  873. module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
  874. script = get_object_or_404(Script.objects.all(), module=module, name=name)
  875. url = reverse('extras:script', kwargs={'pk': script.pk})
  876. return redirect(f'{url}{path}')
  877. class ScriptResultView(TableMixin, generic.ObjectView):
  878. queryset = Job.objects.all()
  879. def get_required_permission(self):
  880. return 'extras.view_script'
  881. def get_table(self, job, request, bulk_actions=True):
  882. data = []
  883. tests = None
  884. table = None
  885. index = 0
  886. if job.data:
  887. if 'log' in job.data:
  888. if 'tests' in job.data:
  889. tests = job.data['tests']
  890. for log in job.data['log']:
  891. index += 1
  892. result = {
  893. 'index': index,
  894. 'time': log.get('time'),
  895. 'status': log.get('status'),
  896. 'message': log.get('message'),
  897. 'object': log.get('obj'),
  898. 'url': log.get('url'),
  899. }
  900. data.append(result)
  901. table = ScriptResultsTable(data, user=request.user)
  902. table.configure(request)
  903. else:
  904. # for legacy reports
  905. tests = job.data
  906. if tests:
  907. for method, test_data in tests.items():
  908. if 'log' in test_data:
  909. for time, status, obj, url, message in test_data['log']:
  910. index += 1
  911. result = {
  912. 'index': index,
  913. 'method': method,
  914. 'time': time,
  915. 'status': status,
  916. 'object': obj,
  917. 'url': url,
  918. 'message': message,
  919. }
  920. data.append(result)
  921. table = ReportResultsTable(data, user=request.user)
  922. table.configure(request)
  923. return table
  924. def get(self, request, **kwargs):
  925. table = None
  926. job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
  927. if job.completed:
  928. table = self.get_table(job, request, bulk_actions=False)
  929. context = {
  930. 'script': job.object,
  931. 'job': job,
  932. 'table': table,
  933. }
  934. if job.data and 'log' in job.data:
  935. # Script
  936. context['tests'] = job.data.get('tests', {})
  937. elif job.data:
  938. # Legacy Report
  939. context['tests'] = {
  940. name: data for name, data in job.data.items()
  941. if name.startswith('test_')
  942. }
  943. # If this is an HTMX request, return only the result HTML
  944. if htmx_partial(request):
  945. response = render(request, 'extras/htmx/script_result.html', context)
  946. if job.completed or not job.started:
  947. response.status_code = 286
  948. return response
  949. return render(request, 'extras/script_result.html', context)
  950. #
  951. # Markdown
  952. #
  953. class RenderMarkdownView(View):
  954. def post(self, request):
  955. form = forms.RenderMarkdownForm(request.POST)
  956. if not form.is_valid():
  957. HttpResponseBadRequest()
  958. rendered = render_markdown(form.cleaned_data['text'])
  959. return HttpResponse(rendered)