views.py 30 KB

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