views.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. import json
  2. import platform
  3. from copy import deepcopy
  4. from django import __version__ as django_version
  5. from django.conf import settings
  6. from django.contrib import messages
  7. from django.contrib.auth.mixins import UserPassesTestMixin
  8. from django.core.cache import cache
  9. from django.db import ProgrammingError, connection
  10. from django.http import Http404, HttpResponse, HttpResponseForbidden
  11. from django.shortcuts import get_object_or_404, redirect, render
  12. from django.urls import reverse
  13. from django.utils.translation import gettext_lazy as _
  14. from django.views.generic import View
  15. from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
  16. from django_rq.settings import QUEUES_LIST, QUEUES_MAP
  17. from django_rq.utils import get_statistics
  18. from rq.exceptions import NoSuchJobError
  19. from rq.job import Job as RQ_Job
  20. from rq.job import JobStatus as RQJobStatus
  21. from rq.worker import Worker
  22. from rq.worker_registration import clean_worker_registry
  23. from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
  24. from extras.ui.panels import CustomFieldsPanel, TagsPanel
  25. from netbox.config import PARAMS, get_config
  26. from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
  27. from netbox.plugins.utils import get_installed_plugins
  28. from netbox.ui import layout
  29. from netbox.ui.panels import (
  30. CommentsPanel,
  31. ContextTablePanel,
  32. JSONPanel,
  33. ObjectsTablePanel,
  34. PluginContentPanel,
  35. RelatedObjectsPanel,
  36. TemplatePanel,
  37. )
  38. from netbox.views import generic
  39. from netbox.views.generic.base import BaseObjectView
  40. from netbox.views.generic.mixins import TableMixin
  41. from utilities.apps import get_installed_apps
  42. from utilities.data import deep_compare_dict
  43. from utilities.forms import ConfirmationForm
  44. from utilities.htmx import htmx_partial
  45. from utilities.json import ConfigJSONEncoder
  46. from utilities.query import count_related
  47. from utilities.views import (
  48. ContentTypePermissionRequiredMixin,
  49. GetRelatedModelsMixin,
  50. GetReturnURLMixin,
  51. ViewTab,
  52. register_model_view,
  53. )
  54. from . import filtersets, forms, tables
  55. from .jobs import SyncDataSourceJob
  56. from .models import *
  57. from .plugins import get_catalog_plugins, get_local_plugins
  58. from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
  59. from .ui import panels
  60. #
  61. # Data sources
  62. #
  63. @register_model_view(DataSource, 'list', path='', detail=False)
  64. class DataSourceListView(generic.ObjectListView):
  65. queryset = DataSource.objects.annotate(
  66. file_count=count_related(DataFile, 'source')
  67. )
  68. filterset = filtersets.DataSourceFilterSet
  69. filterset_form = forms.DataSourceFilterForm
  70. table = tables.DataSourceTable
  71. @register_model_view(DataSource)
  72. class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
  73. queryset = DataSource.objects.all()
  74. layout = layout.SimpleLayout(
  75. left_panels=[
  76. panels.DataSourcePanel(),
  77. TagsPanel(),
  78. CommentsPanel(),
  79. ],
  80. right_panels=[
  81. panels.DataSourceBackendPanel(),
  82. RelatedObjectsPanel(),
  83. CustomFieldsPanel(),
  84. ],
  85. bottom_panels=[
  86. ObjectsTablePanel(
  87. model='core.DataFile',
  88. filters={'source_id': lambda ctx: ctx['object'].pk},
  89. exclude_columns=['source'],
  90. ),
  91. ],
  92. )
  93. def get_extra_context(self, request, instance):
  94. return {
  95. 'related_models': self.get_related_models(request, instance),
  96. }
  97. @register_model_view(DataSource, 'sync')
  98. class DataSourceSyncView(GetReturnURLMixin, BaseObjectView):
  99. queryset = DataSource.objects.all()
  100. def get_required_permission(self):
  101. return 'core.sync_datasource'
  102. def get(self, request, pk):
  103. # Redirect GET requests to the object view
  104. datasource = get_object_or_404(self.queryset, pk=pk)
  105. return redirect(datasource.get_absolute_url())
  106. def post(self, request, pk):
  107. datasource = get_object_or_404(self.queryset, pk=pk)
  108. # Enqueue the sync job
  109. job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
  110. messages.success(
  111. request,
  112. _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
  113. )
  114. return redirect(self.get_return_url(request, datasource))
  115. @register_model_view(DataSource, 'add', detail=False)
  116. @register_model_view(DataSource, 'edit')
  117. class DataSourceEditView(generic.ObjectEditView):
  118. queryset = DataSource.objects.all()
  119. form = forms.DataSourceForm
  120. @register_model_view(DataSource, 'delete')
  121. class DataSourceDeleteView(generic.ObjectDeleteView):
  122. queryset = DataSource.objects.all()
  123. @register_model_view(DataSource, 'bulk_import', path='import', detail=False)
  124. class DataSourceBulkImportView(generic.BulkImportView):
  125. queryset = DataSource.objects.all()
  126. model_form = forms.DataSourceImportForm
  127. @register_model_view(DataSource, 'bulk_edit', path='edit', detail=False)
  128. class DataSourceBulkEditView(generic.BulkEditView):
  129. queryset = DataSource.objects.annotate(
  130. count_files=count_related(DataFile, 'source')
  131. )
  132. filterset = filtersets.DataSourceFilterSet
  133. table = tables.DataSourceTable
  134. form = forms.DataSourceBulkEditForm
  135. @register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
  136. class DataSourceBulkRenameView(generic.BulkRenameView):
  137. queryset = DataSource.objects.all()
  138. filterset = filtersets.DataSourceFilterSet
  139. @register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
  140. class DataSourceBulkDeleteView(generic.BulkDeleteView):
  141. queryset = DataSource.objects.annotate(
  142. count_files=count_related(DataFile, 'source')
  143. )
  144. filterset = filtersets.DataSourceFilterSet
  145. table = tables.DataSourceTable
  146. #
  147. # Data files
  148. #
  149. @register_model_view(DataFile, 'list', path='', detail=False)
  150. class DataFileListView(generic.ObjectListView):
  151. queryset = DataFile.objects.defer('data')
  152. filterset = filtersets.DataFileFilterSet
  153. filterset_form = forms.DataFileFilterForm
  154. table = tables.DataFileTable
  155. actions = (BulkDelete,)
  156. @register_model_view(DataFile)
  157. class DataFileView(generic.ObjectView):
  158. queryset = DataFile.objects.all()
  159. actions = (DeleteObject,)
  160. layout = layout.Layout(
  161. layout.Row(
  162. layout.Column(
  163. panels.DataFilePanel(),
  164. panels.DataFileContentPanel(),
  165. ),
  166. ),
  167. )
  168. @register_model_view(DataFile, 'delete')
  169. class DataFileDeleteView(generic.ObjectDeleteView):
  170. queryset = DataFile.objects.all()
  171. @register_model_view(DataFile, 'bulk_delete', path='delete', detail=False)
  172. class DataFileBulkDeleteView(generic.BulkDeleteView):
  173. queryset = DataFile.objects.defer('data')
  174. filterset = filtersets.DataFileFilterSet
  175. table = tables.DataFileTable
  176. #
  177. # Jobs
  178. #
  179. @register_model_view(Job, 'list', path='', detail=False)
  180. class JobListView(generic.ObjectListView):
  181. queryset = Job.objects.defer('data')
  182. filterset = filtersets.JobFilterSet
  183. filterset_form = forms.JobFilterForm
  184. table = tables.JobTable
  185. actions = (BulkExport, BulkDelete)
  186. @register_model_view(Job)
  187. class JobView(generic.ObjectView):
  188. queryset = Job.objects.all()
  189. actions = (DeleteObject,)
  190. layout = layout.SimpleLayout(
  191. left_panels=[
  192. panels.JobPanel(),
  193. ],
  194. right_panels=[
  195. panels.JobSchedulingPanel(),
  196. ],
  197. bottom_panels=[
  198. JSONPanel('data', title=_('Data')),
  199. ],
  200. )
  201. @register_model_view(Job, 'log')
  202. class JobLogView(generic.ObjectView):
  203. queryset = Job.objects.all()
  204. actions = (DeleteObject,)
  205. template_name = 'core/job/log.html'
  206. tab = ViewTab(
  207. label=_('Log'),
  208. badge=lambda obj: len(obj.log_entries),
  209. weight=500,
  210. )
  211. layout = layout.Layout(
  212. layout.Row(
  213. layout.Column(
  214. ContextTablePanel('table', title=_('Log Entries')),
  215. ),
  216. ),
  217. )
  218. def get_extra_context(self, request, instance):
  219. table = JobLogEntryTable(instance.log_entries)
  220. table.configure(request)
  221. return {
  222. 'table': table,
  223. }
  224. @register_model_view(Job, 'delete')
  225. class JobDeleteView(generic.ObjectDeleteView):
  226. queryset = Job.objects.defer('data')
  227. @register_model_view(Job, 'bulk_delete', path='delete', detail=False)
  228. class JobBulkDeleteView(generic.BulkDeleteView):
  229. queryset = Job.objects.defer('data')
  230. filterset = filtersets.JobFilterSet
  231. table = tables.JobTable
  232. #
  233. # Change logging
  234. #
  235. @register_model_view(ObjectChange, 'list', path='', detail=False)
  236. class ObjectChangeListView(generic.ObjectListView):
  237. queryset = None
  238. filterset = filtersets.ObjectChangeFilterSet
  239. filterset_form = forms.ObjectChangeFilterForm
  240. table = tables.ObjectChangeTable
  241. template_name = 'core/objectchange_list.html'
  242. actions = (BulkExport,)
  243. def get_queryset(self, request):
  244. return ObjectChange.objects.valid_models()
  245. @register_model_view(ObjectChange)
  246. class ObjectChangeView(generic.ObjectView):
  247. queryset = None
  248. layout = layout.Layout(
  249. layout.Row(
  250. layout.Column(panels.ObjectChangePanel()),
  251. layout.Column(TemplatePanel('core/panels/objectchange_difference.html')),
  252. ),
  253. layout.Row(
  254. layout.Column(TemplatePanel('core/panels/objectchange_prechange.html')),
  255. layout.Column(TemplatePanel('core/panels/objectchange_postchange.html')),
  256. ),
  257. layout.Row(
  258. layout.Column(PluginContentPanel('left_page')),
  259. layout.Column(PluginContentPanel('right_page')),
  260. ),
  261. layout.Row(
  262. layout.Column(
  263. TemplatePanel('core/panels/objectchange_related.html'),
  264. PluginContentPanel('full_width_page'),
  265. ),
  266. ),
  267. )
  268. def get_queryset(self, request):
  269. return ObjectChange.objects.valid_models()
  270. def get_extra_context(self, request, instance):
  271. related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  272. request_id=instance.request_id
  273. ).exclude(
  274. pk=instance.pk
  275. )
  276. related_changes_table = tables.ObjectChangeTable(
  277. data=related_changes[:50],
  278. orderable=False
  279. )
  280. related_changes_table.configure(request)
  281. objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
  282. changed_object_type=instance.changed_object_type,
  283. changed_object_id=instance.changed_object_id,
  284. )
  285. next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
  286. prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
  287. if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
  288. non_atomic_change = True
  289. prechange_data = prev_change.postchange_data_clean
  290. else:
  291. non_atomic_change = False
  292. prechange_data = instance.prechange_data_clean
  293. if prechange_data and instance.postchange_data:
  294. diff_added, diff_removed = deep_compare_dict(
  295. prechange_data,
  296. instance.postchange_data_clean,
  297. exclude=['last_updated'],
  298. )
  299. else:
  300. diff_added = {}
  301. diff_removed = {}
  302. return {
  303. 'diff_added': diff_added,
  304. 'diff_removed': diff_removed,
  305. 'next_change': next_change,
  306. 'prev_change': prev_change,
  307. 'related_changes_table': related_changes_table,
  308. 'related_changes_count': related_changes.count(),
  309. 'non_atomic_change': non_atomic_change
  310. }
  311. #
  312. # Config Revisions
  313. #
  314. @register_model_view(ConfigRevision, 'list', path='', detail=False)
  315. class ConfigRevisionListView(generic.ObjectListView):
  316. queryset = ConfigRevision.objects.all()
  317. filterset = filtersets.ConfigRevisionFilterSet
  318. filterset_form = forms.ConfigRevisionFilterForm
  319. table = tables.ConfigRevisionTable
  320. actions = (AddObject, BulkExport)
  321. @register_model_view(ConfigRevision)
  322. class ConfigRevisionView(generic.ObjectView):
  323. queryset = ConfigRevision.objects.all()
  324. layout = layout.Layout(
  325. layout.Row(
  326. layout.Column(
  327. TemplatePanel('core/panels/configrevision_data.html'),
  328. TemplatePanel('core/panels/configrevision_comment.html'),
  329. ),
  330. ),
  331. )
  332. def get_extra_context(self, request, instance):
  333. """
  334. Retrieve additional context for a given request and instance.
  335. """
  336. # Copy the revision data to avoid modifying the original
  337. config = deepcopy(instance.data or {})
  338. # Serialize any JSON-based classes
  339. for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
  340. if attr in config:
  341. config[attr] = json.dumps(config[attr], cls=ConfigJSONEncoder, indent=4)
  342. return {
  343. 'config': config,
  344. }
  345. @register_model_view(ConfigRevision, 'add', detail=False)
  346. class ConfigRevisionEditView(generic.ObjectEditView):
  347. queryset = ConfigRevision.objects.all()
  348. form = forms.ConfigRevisionForm
  349. @register_model_view(ConfigRevision, 'delete')
  350. class ConfigRevisionDeleteView(generic.ObjectDeleteView):
  351. queryset = ConfigRevision.objects.all()
  352. @register_model_view(ConfigRevision, 'bulk_delete', path='delete', detail=False)
  353. class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
  354. queryset = ConfigRevision.objects.all()
  355. filterset = filtersets.ConfigRevisionFilterSet
  356. table = tables.ConfigRevisionTable
  357. @register_model_view(ConfigRevision, 'restore')
  358. class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
  359. def get_required_permission(self):
  360. return 'core.configrevision_edit'
  361. def get(self, request, pk):
  362. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  363. # Get the current ConfigRevision
  364. config_version = get_config().version
  365. current_config = ConfigRevision.objects.filter(pk=config_version).first()
  366. params = []
  367. for param in PARAMS:
  368. params.append((
  369. param.name,
  370. current_config.data.get(param.name, None) if current_config else None,
  371. candidate_config.data.get(param.name, None)
  372. ))
  373. return render(request, 'core/configrevision_restore.html', {
  374. 'object': candidate_config,
  375. 'params': params,
  376. })
  377. def post(self, request, pk):
  378. if not request.user.has_perm('core.configrevision_edit'):
  379. return HttpResponseForbidden()
  380. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  381. candidate_config.activate()
  382. messages.success(request, _("Restored configuration revision #{id}").format(id=pk))
  383. return redirect(candidate_config.get_absolute_url())
  384. #
  385. # Background Tasks (RQ)
  386. #
  387. class BaseRQView(UserPassesTestMixin, View):
  388. def test_func(self):
  389. return self.request.user.is_superuser
  390. class BackgroundQueueListView(TableMixin, BaseRQView):
  391. table = tables.BackgroundQueueTable
  392. def get(self, request):
  393. data = get_statistics(run_maintenance_tasks=True)["queues"]
  394. table = self.get_table(data, request, bulk_actions=False)
  395. return render(request, 'core/rq_queue_list.html', {
  396. 'table': table,
  397. })
  398. class BackgroundTaskListView(TableMixin, BaseRQView):
  399. table = tables.BackgroundTaskTable
  400. def get_table_data(self, request, queue, status):
  401. # Call get_jobs() to returned queued tasks
  402. if status == RQJobStatus.QUEUED:
  403. return queue.get_jobs()
  404. return get_rq_jobs_from_status(queue, status)
  405. def get(self, request, queue_index, status):
  406. queue = get_queue_by_index(queue_index)
  407. data = self.get_table_data(request, queue, status)
  408. table = self.get_table(data, request, False)
  409. # If this is an HTMX request, return only the rendered table HTML
  410. if htmx_partial(request):
  411. return render(request, 'htmx/table.html', {
  412. 'table': table,
  413. })
  414. return render(request, 'core/rq_task_list.html', {
  415. 'table': table,
  416. 'queue': queue,
  417. 'status': status,
  418. })
  419. class BackgroundTaskView(BaseRQView):
  420. def get(self, request, job_id):
  421. # all the RQ queues should use the same connection
  422. config = QUEUES_LIST[0]
  423. try:
  424. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  425. except NoSuchJobError:
  426. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  427. queue_index = QUEUES_MAP[job.origin]
  428. queue = get_queue_by_index(queue_index)
  429. try:
  430. exc_info = job._exc_info
  431. except AttributeError:
  432. exc_info = None
  433. return render(request, 'core/rq_task.html', {
  434. 'queue': queue,
  435. 'job': job,
  436. 'queue_index': queue_index,
  437. 'dependency_id': job._dependency_id,
  438. 'exc_info': exc_info,
  439. })
  440. class BackgroundTaskDeleteView(BaseRQView):
  441. def get(self, request, job_id):
  442. if not request.htmx:
  443. return redirect(reverse('core:background_queue_list'))
  444. form = ConfirmationForm(initial=request.GET)
  445. return render(request, 'htmx/delete_form.html', {
  446. 'object_type': 'background task',
  447. 'object': job_id,
  448. 'form': form,
  449. 'form_url': reverse('core:background_task_delete', kwargs={'job_id': job_id})
  450. })
  451. def post(self, request, job_id):
  452. form = ConfirmationForm(request.POST)
  453. if form.is_valid():
  454. delete_rq_job(job_id)
  455. messages.success(request, _('Job {id} has been deleted.').format(id=job_id))
  456. else:
  457. messages.error(request, _('Error deleting job {id}: {error}').format(id=job_id, error=form.errors[0]))
  458. return redirect(reverse('core:background_queue_list'))
  459. class BackgroundTaskRequeueView(BaseRQView):
  460. def get(self, request, job_id):
  461. requeue_rq_job(job_id)
  462. messages.success(request, _('Job {id} has been re-enqueued.').format(id=job_id))
  463. return redirect(reverse('core:background_task', args=[job_id]))
  464. class BackgroundTaskEnqueueView(BaseRQView):
  465. def get(self, request, job_id):
  466. # all the RQ queues should use the same connection
  467. enqueue_rq_job(job_id)
  468. messages.success(request, _('Job {id} has been enqueued.').format(id=job_id))
  469. return redirect(reverse('core:background_task', args=[job_id]))
  470. class BackgroundTaskStopView(BaseRQView):
  471. def get(self, request, job_id):
  472. stopped_jobs = stop_rq_job(job_id)
  473. if len(stopped_jobs) == 1:
  474. messages.success(request, _('Job {id} has been stopped.').format(id=job_id))
  475. else:
  476. messages.error(request, _('Failed to stop job {id}').format(id=job_id))
  477. return redirect(reverse('core:background_task', args=[job_id]))
  478. class WorkerListView(TableMixin, BaseRQView):
  479. table = tables.WorkerTable
  480. def get_table_data(self, request, queue):
  481. clean_worker_registry(queue)
  482. all_workers = Worker.all(queue.connection)
  483. workers = [worker for worker in all_workers if queue.name in worker.queue_names()]
  484. return workers
  485. def get(self, request, queue_index):
  486. queue = get_queue_by_index(queue_index)
  487. data = self.get_table_data(request, queue)
  488. table = self.get_table(data, request, False)
  489. # If this is an HTMX request, return only the rendered table HTML
  490. if htmx_partial(request):
  491. if not request.htmx.target:
  492. table.embedded = True
  493. # Hide selection checkboxes
  494. if 'pk' in table.base_columns:
  495. table.columns.hide('pk')
  496. return render(request, 'htmx/table.html', {
  497. 'table': table,
  498. 'queue': queue,
  499. })
  500. return render(request, 'core/rq_worker_list.html', {
  501. 'table': table,
  502. 'queue': queue,
  503. })
  504. class WorkerView(BaseRQView):
  505. def get(self, request, key):
  506. # all the RQ queues should use the same connection
  507. config = QUEUES_LIST[0]
  508. worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
  509. # Convert microseconds to milliseconds
  510. worker.total_working_time = worker.total_working_time / 1000
  511. return render(request, 'core/rq_worker.html', {
  512. 'worker': worker,
  513. 'job': worker.get_current_job(),
  514. 'total_working_time': worker.total_working_time * 1000,
  515. })
  516. #
  517. # System
  518. #
  519. class SystemView(UserPassesTestMixin, View):
  520. def test_func(self):
  521. return self.request.user.is_superuser
  522. def get(self, request):
  523. # System status
  524. psql_version = db_name = db_size = None
  525. try:
  526. with connection.cursor() as cursor:
  527. cursor.execute("SELECT version()")
  528. psql_version = cursor.fetchone()[0]
  529. psql_version = psql_version.split('(')[0].strip()
  530. cursor.execute("SELECT current_database()")
  531. db_name = cursor.fetchone()[0]
  532. cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
  533. db_size = cursor.fetchone()[0]
  534. except (ProgrammingError, IndexError):
  535. pass
  536. stats = {
  537. 'netbox_release': settings.RELEASE,
  538. 'django_version': django_version,
  539. 'python_version': platform.python_version(),
  540. 'postgresql_version': psql_version,
  541. 'database_name': db_name,
  542. 'database_size': db_size,
  543. 'rq_worker_count': Worker.count(get_connection('default')),
  544. }
  545. # Django apps
  546. django_apps = get_installed_apps()
  547. # Configuration
  548. config = get_config()
  549. # Plugins
  550. plugins = get_installed_plugins()
  551. # Object counts
  552. objects = {}
  553. for ot in ObjectType.objects.public().order_by('app_label', 'model'):
  554. if model := ot.model_class():
  555. objects[ot] = model.objects.count()
  556. # Raw data export
  557. if 'export' in request.GET:
  558. stats['netbox_release'] = stats['netbox_release'].asdict()
  559. params = [param.name for param in PARAMS]
  560. data = {
  561. **stats,
  562. 'django_apps': django_apps,
  563. 'plugins': plugins,
  564. 'config': {
  565. k: getattr(config, k) for k in sorted(params)
  566. },
  567. 'objects': {
  568. f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
  569. },
  570. }
  571. response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
  572. response['Content-Disposition'] = 'attachment; filename="netbox.json"'
  573. return response
  574. # Serialize any JSON-based classes
  575. for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
  576. if hasattr(config, attr) and getattr(config, attr, None):
  577. setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
  578. return render(request, 'core/system.html', {
  579. 'stats': stats,
  580. 'django_apps': django_apps,
  581. 'config': config,
  582. 'plugins': plugins,
  583. 'objects': objects,
  584. })
  585. #
  586. # Plugins
  587. #
  588. class BasePluginView(UserPassesTestMixin, View):
  589. CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
  590. def test_func(self):
  591. return self.request.user.is_superuser
  592. def get_cached_plugins(self, request):
  593. catalog_plugins = {}
  594. catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
  595. if not catalog_plugins_error:
  596. catalog_plugins = get_catalog_plugins()
  597. if not catalog_plugins and not settings.ISOLATED_DEPLOYMENT:
  598. # Cache for 5 minutes to avoid spamming connection
  599. cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
  600. messages.warning(request, _("Plugins catalog could not be loaded"))
  601. return get_local_plugins(catalog_plugins)
  602. class PluginListView(BasePluginView):
  603. def get(self, request):
  604. q = request.GET.get('q', None)
  605. plugins = self.get_cached_plugins(request).values()
  606. if q:
  607. plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
  608. plugins = [plugin for plugin in plugins if not plugin.hidden]
  609. table = CatalogPluginTable(plugins)
  610. table.configure(request)
  611. # If this is an HTMX request, return only the rendered table HTML
  612. if htmx_partial(request):
  613. return render(request, 'htmx/table.html', {
  614. 'table': table,
  615. })
  616. return render(request, 'core/plugin_list.html', {
  617. 'table': table,
  618. })
  619. class PluginView(BasePluginView):
  620. def get(self, request, name):
  621. plugins = self.get_cached_plugins(request)
  622. if name not in plugins:
  623. raise Http404(_("Plugin {name} not found").format(name=name))
  624. plugin = plugins[name]
  625. table = PluginVersionTable(plugin.release_recent_history)
  626. table.configure(request)
  627. return render(request, 'core/plugin.html', {
  628. 'plugin': plugin,
  629. 'table': table,
  630. })