views.py 25 KB

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