views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. import json
  2. import platform
  3. from django import __version__ as DJANGO_VERSION
  4. from django.conf import settings
  5. from django.contrib import messages
  6. from django.contrib.auth.mixins import UserPassesTestMixin
  7. from django.core.cache import cache
  8. from django.db import connection, ProgrammingError
  9. from django.http import HttpResponse, HttpResponseForbidden, Http404
  10. from django.shortcuts import get_object_or_404, redirect, render
  11. from django.urls import reverse
  12. from django.utils.translation import gettext_lazy as _
  13. from django.views.generic import View
  14. from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
  15. from django_rq.settings import QUEUES_MAP, QUEUES_LIST
  16. from django_rq.utils import get_statistics
  17. from rq.exceptions import NoSuchJobError
  18. from rq.job import Job as RQ_Job, JobStatus as RQJobStatus
  19. from rq.worker import Worker
  20. from rq.worker_registration import clean_worker_registry
  21. from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
  22. from netbox.config import get_config, PARAMS
  23. from netbox.views import generic
  24. from netbox.views.generic.base import BaseObjectView
  25. from netbox.views.generic.mixins import TableMixin
  26. from utilities.data import shallow_compare_dict
  27. from utilities.forms import ConfirmationForm
  28. from utilities.htmx import htmx_partial
  29. from utilities.json import ConfigJSONEncoder
  30. from utilities.query import count_related
  31. from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
  32. from . import filtersets, forms, tables
  33. from .choices import DataSourceStatusChoices
  34. from .jobs import SyncDataSourceJob
  35. from .models import *
  36. from .plugins import get_catalog_plugins, get_local_plugins
  37. from .tables import CatalogPluginTable, PluginVersionTable
  38. #
  39. # Data sources
  40. #
  41. @register_model_view(DataSource, 'list', path='', detail=False)
  42. class DataSourceListView(generic.ObjectListView):
  43. queryset = DataSource.objects.annotate(
  44. file_count=count_related(DataFile, 'source')
  45. )
  46. filterset = filtersets.DataSourceFilterSet
  47. filterset_form = forms.DataSourceFilterForm
  48. table = tables.DataSourceTable
  49. @register_model_view(DataSource)
  50. class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
  51. queryset = DataSource.objects.all()
  52. def get_extra_context(self, request, instance):
  53. return {
  54. 'related_models': self.get_related_models(request, instance),
  55. }
  56. @register_model_view(DataSource, 'sync')
  57. class DataSourceSyncView(BaseObjectView):
  58. queryset = DataSource.objects.all()
  59. def get_required_permission(self):
  60. return 'core.sync_datasource'
  61. def get(self, request, pk):
  62. # Redirect GET requests to the object view
  63. datasource = get_object_or_404(self.queryset, pk=pk)
  64. return redirect(datasource.get_absolute_url())
  65. def post(self, request, pk):
  66. datasource = get_object_or_404(self.queryset, pk=pk)
  67. # Enqueue the sync job & update the DataSource's status
  68. job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
  69. datasource.status = DataSourceStatusChoices.QUEUED
  70. DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
  71. messages.success(
  72. request,
  73. _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
  74. )
  75. return redirect(datasource.get_absolute_url())
  76. @register_model_view(DataSource, 'add', detail=False)
  77. @register_model_view(DataSource, 'edit')
  78. class DataSourceEditView(generic.ObjectEditView):
  79. queryset = DataSource.objects.all()
  80. form = forms.DataSourceForm
  81. @register_model_view(DataSource, 'delete')
  82. class DataSourceDeleteView(generic.ObjectDeleteView):
  83. queryset = DataSource.objects.all()
  84. @register_model_view(DataSource, 'bulk_import', detail=False)
  85. class DataSourceBulkImportView(generic.BulkImportView):
  86. queryset = DataSource.objects.all()
  87. model_form = forms.DataSourceImportForm
  88. @register_model_view(DataSource, 'bulk_edit', path='edit', detail=False)
  89. class DataSourceBulkEditView(generic.BulkEditView):
  90. queryset = DataSource.objects.annotate(
  91. count_files=count_related(DataFile, 'source')
  92. )
  93. filterset = filtersets.DataSourceFilterSet
  94. table = tables.DataSourceTable
  95. form = forms.DataSourceBulkEditForm
  96. @register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
  97. class DataSourceBulkDeleteView(generic.BulkDeleteView):
  98. queryset = DataSource.objects.annotate(
  99. count_files=count_related(DataFile, 'source')
  100. )
  101. filterset = filtersets.DataSourceFilterSet
  102. table = tables.DataSourceTable
  103. #
  104. # Data files
  105. #
  106. @register_model_view(DataFile, 'list', path='', detail=False)
  107. class DataFileListView(generic.ObjectListView):
  108. queryset = DataFile.objects.defer('data')
  109. filterset = filtersets.DataFileFilterSet
  110. filterset_form = forms.DataFileFilterForm
  111. table = tables.DataFileTable
  112. actions = {
  113. 'bulk_delete': {'delete'},
  114. }
  115. @register_model_view(DataFile)
  116. class DataFileView(generic.ObjectView):
  117. queryset = DataFile.objects.all()
  118. @register_model_view(DataFile, 'delete')
  119. class DataFileDeleteView(generic.ObjectDeleteView):
  120. queryset = DataFile.objects.all()
  121. @register_model_view(DataFile, 'bulk_delete', path='delete', detail=False)
  122. class DataFileBulkDeleteView(generic.BulkDeleteView):
  123. queryset = DataFile.objects.defer('data')
  124. filterset = filtersets.DataFileFilterSet
  125. table = tables.DataFileTable
  126. #
  127. # Jobs
  128. #
  129. @register_model_view(Job, 'list', path='', detail=False)
  130. class JobListView(generic.ObjectListView):
  131. queryset = Job.objects.defer('data')
  132. filterset = filtersets.JobFilterSet
  133. filterset_form = forms.JobFilterForm
  134. table = tables.JobTable
  135. actions = {
  136. 'export': {'view'},
  137. 'bulk_delete': {'delete'},
  138. }
  139. @register_model_view(Job)
  140. class JobView(generic.ObjectView):
  141. queryset = Job.objects.all()
  142. @register_model_view(Job, 'delete')
  143. class JobDeleteView(generic.ObjectDeleteView):
  144. queryset = Job.objects.defer('data')
  145. @register_model_view(Job, 'bulk_delete', path='delete', detail=False)
  146. class JobBulkDeleteView(generic.BulkDeleteView):
  147. queryset = Job.objects.defer('data')
  148. filterset = filtersets.JobFilterSet
  149. table = tables.JobTable
  150. #
  151. # Change logging
  152. #
  153. @register_model_view(ObjectChange, 'list', path='', detail=False)
  154. class ObjectChangeListView(generic.ObjectListView):
  155. queryset = ObjectChange.objects.valid_models()
  156. filterset = filtersets.ObjectChangeFilterSet
  157. filterset_form = forms.ObjectChangeFilterForm
  158. table = tables.ObjectChangeTable
  159. template_name = 'core/objectchange_list.html'
  160. actions = {
  161. 'export': {'view'},
  162. }
  163. @register_model_view(ObjectChange)
  164. class ObjectChangeView(generic.ObjectView):
  165. queryset = ObjectChange.objects.valid_models()
  166. def get_extra_context(self, request, instance):
  167. related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  168. request_id=instance.request_id
  169. ).exclude(
  170. pk=instance.pk
  171. )
  172. related_changes_table = tables.ObjectChangeTable(
  173. data=related_changes[:50],
  174. orderable=False
  175. )
  176. objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  177. changed_object_type=instance.changed_object_type,
  178. changed_object_id=instance.changed_object_id,
  179. )
  180. next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
  181. prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
  182. if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
  183. non_atomic_change = True
  184. prechange_data = prev_change.postchange_data_clean
  185. else:
  186. non_atomic_change = False
  187. prechange_data = instance.prechange_data_clean
  188. if prechange_data and instance.postchange_data:
  189. diff_added = shallow_compare_dict(
  190. prechange_data or dict(),
  191. instance.postchange_data_clean or dict(),
  192. exclude=['last_updated'],
  193. )
  194. diff_removed = {
  195. x: prechange_data.get(x) for x in diff_added
  196. } if prechange_data else {}
  197. else:
  198. diff_added = None
  199. diff_removed = None
  200. return {
  201. 'diff_added': diff_added,
  202. 'diff_removed': diff_removed,
  203. 'next_change': next_change,
  204. 'prev_change': prev_change,
  205. 'related_changes_table': related_changes_table,
  206. 'related_changes_count': related_changes.count(),
  207. 'non_atomic_change': non_atomic_change
  208. }
  209. #
  210. # Config Revisions
  211. #
  212. @register_model_view(ConfigRevision, 'list', path='', detail=False)
  213. class ConfigRevisionListView(generic.ObjectListView):
  214. queryset = ConfigRevision.objects.all()
  215. filterset = filtersets.ConfigRevisionFilterSet
  216. filterset_form = forms.ConfigRevisionFilterForm
  217. table = tables.ConfigRevisionTable
  218. @register_model_view(ConfigRevision)
  219. class ConfigRevisionView(generic.ObjectView):
  220. queryset = ConfigRevision.objects.all()
  221. @register_model_view(ConfigRevision, 'add', detail=False)
  222. class ConfigRevisionEditView(generic.ObjectEditView):
  223. queryset = ConfigRevision.objects.all()
  224. form = forms.ConfigRevisionForm
  225. @register_model_view(ConfigRevision, 'delete')
  226. class ConfigRevisionDeleteView(generic.ObjectDeleteView):
  227. queryset = ConfigRevision.objects.all()
  228. @register_model_view(ConfigRevision, 'bulk_delete', path='delete', detail=False)
  229. class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
  230. queryset = ConfigRevision.objects.all()
  231. filterset = filtersets.ConfigRevisionFilterSet
  232. table = tables.ConfigRevisionTable
  233. @register_model_view(ConfigRevision, 'restore')
  234. class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
  235. def get_required_permission(self):
  236. return 'core.configrevision_edit'
  237. def get(self, request, pk):
  238. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  239. # Get the current ConfigRevision
  240. config_version = get_config().version
  241. current_config = ConfigRevision.objects.filter(pk=config_version).first()
  242. params = []
  243. for param in PARAMS:
  244. params.append((
  245. param.name,
  246. current_config.data.get(param.name, None) if current_config else None,
  247. candidate_config.data.get(param.name, None)
  248. ))
  249. return render(request, 'core/configrevision_restore.html', {
  250. 'object': candidate_config,
  251. 'params': params,
  252. })
  253. def post(self, request, pk):
  254. if not request.user.has_perm('core.configrevision_edit'):
  255. return HttpResponseForbidden()
  256. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  257. candidate_config.activate()
  258. messages.success(request, _("Restored configuration revision #{id}").format(id=pk))
  259. return redirect(candidate_config.get_absolute_url())
  260. #
  261. # Background Tasks (RQ)
  262. #
  263. class BaseRQView(UserPassesTestMixin, View):
  264. def test_func(self):
  265. return self.request.user.is_staff
  266. class BackgroundQueueListView(TableMixin, BaseRQView):
  267. table = tables.BackgroundQueueTable
  268. def get(self, request):
  269. data = get_statistics(run_maintenance_tasks=True)["queues"]
  270. table = self.get_table(data, request, bulk_actions=False)
  271. return render(request, 'core/rq_queue_list.html', {
  272. 'table': table,
  273. })
  274. class BackgroundTaskListView(TableMixin, BaseRQView):
  275. table = tables.BackgroundTaskTable
  276. def get_table_data(self, request, queue, status):
  277. # Call get_jobs() to returned queued tasks
  278. if status == RQJobStatus.QUEUED:
  279. return queue.get_jobs()
  280. return get_rq_jobs_from_status(queue, status)
  281. def get(self, request, queue_index, status):
  282. queue = get_queue_by_index(queue_index)
  283. data = self.get_table_data(request, queue, status)
  284. table = self.get_table(data, request, False)
  285. # If this is an HTMX request, return only the rendered table HTML
  286. if htmx_partial(request):
  287. return render(request, 'htmx/table.html', {
  288. 'table': table,
  289. })
  290. return render(request, 'core/rq_task_list.html', {
  291. 'table': table,
  292. 'queue': queue,
  293. 'status': status,
  294. })
  295. class BackgroundTaskView(BaseRQView):
  296. def get(self, request, job_id):
  297. # all the RQ queues should use the same connection
  298. config = QUEUES_LIST[0]
  299. try:
  300. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  301. except NoSuchJobError:
  302. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  303. queue_index = QUEUES_MAP[job.origin]
  304. queue = get_queue_by_index(queue_index)
  305. try:
  306. exc_info = job._exc_info
  307. except AttributeError:
  308. exc_info = None
  309. return render(request, 'core/rq_task.html', {
  310. 'queue': queue,
  311. 'job': job,
  312. 'queue_index': queue_index,
  313. 'dependency_id': job._dependency_id,
  314. 'exc_info': exc_info,
  315. })
  316. class BackgroundTaskDeleteView(BaseRQView):
  317. def get(self, request, job_id):
  318. if not request.htmx:
  319. return redirect(reverse('core:background_queue_list'))
  320. form = ConfirmationForm(initial=request.GET)
  321. return render(request, 'htmx/delete_form.html', {
  322. 'object_type': 'background task',
  323. 'object': job_id,
  324. 'form': form,
  325. 'form_url': reverse('core:background_task_delete', kwargs={'job_id': job_id})
  326. })
  327. def post(self, request, job_id):
  328. form = ConfirmationForm(request.POST)
  329. if form.is_valid():
  330. delete_rq_job(job_id)
  331. messages.success(request, _('Job {id} has been deleted.').format(id=job_id))
  332. else:
  333. messages.error(request, _('Error deleting job {id}: {error}').format(id=job_id, error=form.errors[0]))
  334. return redirect(reverse('core:background_queue_list'))
  335. class BackgroundTaskRequeueView(BaseRQView):
  336. def get(self, request, job_id):
  337. requeue_rq_job(job_id)
  338. messages.success(request, _('Job {id} has been re-enqueued.').format(id=job_id))
  339. return redirect(reverse('core:background_task', args=[job_id]))
  340. class BackgroundTaskEnqueueView(BaseRQView):
  341. def get(self, request, job_id):
  342. # all the RQ queues should use the same connection
  343. enqueue_rq_job(job_id)
  344. messages.success(request, _('Job {id} has been enqueued.').format(id=job_id))
  345. return redirect(reverse('core:background_task', args=[job_id]))
  346. class BackgroundTaskStopView(BaseRQView):
  347. def get(self, request, job_id):
  348. stopped_jobs = stop_rq_job(job_id)
  349. if len(stopped_jobs) == 1:
  350. messages.success(request, _('Job {id} has been stopped.').format(id=job_id))
  351. else:
  352. messages.error(request, _('Failed to stop job {id}').format(id=job_id))
  353. return redirect(reverse('core:background_task', args=[job_id]))
  354. class WorkerListView(TableMixin, BaseRQView):
  355. table = tables.WorkerTable
  356. def get_table_data(self, request, queue):
  357. clean_worker_registry(queue)
  358. all_workers = Worker.all(queue.connection)
  359. workers = [worker for worker in all_workers if queue.name in worker.queue_names()]
  360. return workers
  361. def get(self, request, queue_index):
  362. queue = get_queue_by_index(queue_index)
  363. data = self.get_table_data(request, queue)
  364. table = self.get_table(data, request, False)
  365. # If this is an HTMX request, return only the rendered table HTML
  366. if htmx_partial(request):
  367. if not request.htmx.target:
  368. table.embedded = True
  369. # Hide selection checkboxes
  370. if 'pk' in table.base_columns:
  371. table.columns.hide('pk')
  372. return render(request, 'htmx/table.html', {
  373. 'table': table,
  374. 'queue': queue,
  375. })
  376. return render(request, 'core/rq_worker_list.html', {
  377. 'table': table,
  378. 'queue': queue,
  379. })
  380. class WorkerView(BaseRQView):
  381. def get(self, request, key):
  382. # all the RQ queues should use the same connection
  383. config = QUEUES_LIST[0]
  384. worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
  385. # Convert microseconds to milliseconds
  386. worker.total_working_time = worker.total_working_time / 1000
  387. return render(request, 'core/rq_worker.html', {
  388. 'worker': worker,
  389. 'job': worker.get_current_job(),
  390. 'total_working_time': worker.total_working_time * 1000,
  391. })
  392. #
  393. # System
  394. #
  395. class SystemView(UserPassesTestMixin, View):
  396. def test_func(self):
  397. return self.request.user.is_staff
  398. def get(self, request):
  399. # System stats
  400. psql_version = db_name = db_size = None
  401. try:
  402. with connection.cursor() as cursor:
  403. cursor.execute("SELECT version()")
  404. psql_version = cursor.fetchone()[0]
  405. psql_version = psql_version.split('(')[0].strip()
  406. cursor.execute("SELECT current_database()")
  407. db_name = cursor.fetchone()[0]
  408. cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
  409. db_size = cursor.fetchone()[0]
  410. except (ProgrammingError, IndexError):
  411. pass
  412. stats = {
  413. 'netbox_release': settings.RELEASE,
  414. 'django_version': DJANGO_VERSION,
  415. 'python_version': platform.python_version(),
  416. 'postgresql_version': psql_version,
  417. 'database_name': db_name,
  418. 'database_size': db_size,
  419. 'rq_worker_count': Worker.count(get_connection('default')),
  420. }
  421. # Configuration
  422. config = get_config()
  423. # Raw data export
  424. if 'export' in request.GET:
  425. stats['netbox_release'] = stats['netbox_release'].asdict()
  426. params = [param.name for param in PARAMS]
  427. data = {
  428. **stats,
  429. 'plugins': settings.PLUGINS,
  430. 'config': {
  431. k: getattr(config, k) for k in sorted(params)
  432. },
  433. }
  434. response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
  435. response['Content-Disposition'] = 'attachment; filename="netbox.json"'
  436. return response
  437. # Serialize any CustomValidator classes
  438. for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
  439. if hasattr(config, attr) and getattr(config, attr, None):
  440. setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
  441. return render(request, 'core/system.html', {
  442. 'stats': stats,
  443. 'config': config,
  444. })
  445. #
  446. # Plugins
  447. #
  448. class BasePluginView(UserPassesTestMixin, View):
  449. CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
  450. def test_func(self):
  451. return self.request.user.is_staff
  452. def get_cached_plugins(self, request):
  453. catalog_plugins = {}
  454. catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
  455. if not catalog_plugins_error:
  456. catalog_plugins = get_catalog_plugins()
  457. if not catalog_plugins and not settings.ISOLATED_DEPLOYMENT:
  458. # Cache for 5 minutes to avoid spamming connection
  459. cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
  460. messages.warning(request, _("Plugins catalog could not be loaded"))
  461. return get_local_plugins(catalog_plugins)
  462. class PluginListView(BasePluginView):
  463. def get(self, request):
  464. q = request.GET.get('q', None)
  465. plugins = self.get_cached_plugins(request).values()
  466. if q:
  467. plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
  468. table = CatalogPluginTable(plugins, user=request.user)
  469. table.configure(request)
  470. # If this is an HTMX request, return only the rendered table HTML
  471. if htmx_partial(request):
  472. return render(request, 'htmx/table.html', {
  473. 'table': table,
  474. })
  475. return render(request, 'core/plugin_list.html', {
  476. 'table': table,
  477. })
  478. class PluginView(BasePluginView):
  479. def get(self, request, name):
  480. plugins = self.get_cached_plugins(request)
  481. if name not in plugins:
  482. raise Http404(_("Plugin {name} not found").format(name=name))
  483. plugin = plugins[name]
  484. table = PluginVersionTable(plugin.release_recent_history, user=request.user)
  485. table.configure(request)
  486. return render(request, 'core/plugin.html', {
  487. 'plugin': plugin,
  488. 'table': table,
  489. })