views.py 28 KB

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