views.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import json
  2. import platform
  3. from django import __version__ as DJANGO_VERSION
  4. from django.apps import apps
  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 connection, ProgrammingError
  10. from django.http import HttpResponse, HttpResponseForbidden, Http404
  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_MAP, QUEUES_LIST
  17. from django_rq.utils import get_jobs, get_statistics, stop_jobs
  18. from rq import requeue_job
  19. from rq.exceptions import NoSuchJobError
  20. from rq.job import Job as RQ_Job, JobStatus as RQJobStatus
  21. from rq.registry import (
  22. DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, ScheduledJobRegistry, StartedJobRegistry,
  23. )
  24. from rq.worker import Worker
  25. from rq.worker_registration import clean_worker_registry
  26. from netbox.config import get_config, PARAMS
  27. from netbox.views import generic
  28. from netbox.views.generic.base import BaseObjectView
  29. from netbox.views.generic.mixins import TableMixin
  30. from utilities.forms import ConfirmationForm
  31. from utilities.htmx import htmx_partial
  32. from utilities.query import count_related
  33. from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
  34. from . import filtersets, forms, tables
  35. from .models import *
  36. #
  37. # Data sources
  38. #
  39. class DataSourceListView(generic.ObjectListView):
  40. queryset = DataSource.objects.annotate(
  41. file_count=count_related(DataFile, 'source')
  42. )
  43. filterset = filtersets.DataSourceFilterSet
  44. filterset_form = forms.DataSourceFilterForm
  45. table = tables.DataSourceTable
  46. @register_model_view(DataSource)
  47. class DataSourceView(generic.ObjectView):
  48. queryset = DataSource.objects.all()
  49. def get_extra_context(self, request, instance):
  50. related_models = (
  51. (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
  52. )
  53. return {
  54. 'related_models': related_models,
  55. }
  56. @register_model_view(DataSource, 'sync')
  57. class DataSourceSyncView(BaseObjectView):
  58. queryset = DataSource.objects.all()
  59. def get_required_permission(self):
  60. return 'core.sync_datasource'
  61. def get(self, request, pk):
  62. # Redirect GET requests to the object view
  63. datasource = get_object_or_404(self.queryset, pk=pk)
  64. return redirect(datasource.get_absolute_url())
  65. def post(self, request, pk):
  66. datasource = get_object_or_404(self.queryset, pk=pk)
  67. job = datasource.enqueue_sync_job(request)
  68. messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
  69. return redirect(datasource.get_absolute_url())
  70. @register_model_view(DataSource, 'edit')
  71. class DataSourceEditView(generic.ObjectEditView):
  72. queryset = DataSource.objects.all()
  73. form = forms.DataSourceForm
  74. @register_model_view(DataSource, 'delete')
  75. class DataSourceDeleteView(generic.ObjectDeleteView):
  76. queryset = DataSource.objects.all()
  77. class DataSourceBulkImportView(generic.BulkImportView):
  78. queryset = DataSource.objects.all()
  79. model_form = forms.DataSourceImportForm
  80. class DataSourceBulkEditView(generic.BulkEditView):
  81. queryset = DataSource.objects.annotate(
  82. count_files=count_related(DataFile, 'source')
  83. )
  84. filterset = filtersets.DataSourceFilterSet
  85. table = tables.DataSourceTable
  86. form = forms.DataSourceBulkEditForm
  87. class DataSourceBulkDeleteView(generic.BulkDeleteView):
  88. queryset = DataSource.objects.annotate(
  89. count_files=count_related(DataFile, 'source')
  90. )
  91. filterset = filtersets.DataSourceFilterSet
  92. table = tables.DataSourceTable
  93. #
  94. # Data files
  95. #
  96. class DataFileListView(generic.ObjectListView):
  97. queryset = DataFile.objects.defer('data')
  98. filterset = filtersets.DataFileFilterSet
  99. filterset_form = forms.DataFileFilterForm
  100. table = tables.DataFileTable
  101. actions = {
  102. 'bulk_delete': {'delete'},
  103. }
  104. @register_model_view(DataFile)
  105. class DataFileView(generic.ObjectView):
  106. queryset = DataFile.objects.all()
  107. @register_model_view(DataFile, 'delete')
  108. class DataFileDeleteView(generic.ObjectDeleteView):
  109. queryset = DataFile.objects.all()
  110. class DataFileBulkDeleteView(generic.BulkDeleteView):
  111. queryset = DataFile.objects.defer('data')
  112. filterset = filtersets.DataFileFilterSet
  113. table = tables.DataFileTable
  114. #
  115. # Jobs
  116. #
  117. class JobListView(generic.ObjectListView):
  118. queryset = Job.objects.all()
  119. filterset = filtersets.JobFilterSet
  120. filterset_form = forms.JobFilterForm
  121. table = tables.JobTable
  122. actions = {
  123. 'export': {'view'},
  124. 'bulk_delete': {'delete'},
  125. }
  126. class JobView(generic.ObjectView):
  127. queryset = Job.objects.all()
  128. class JobDeleteView(generic.ObjectDeleteView):
  129. queryset = Job.objects.all()
  130. class JobBulkDeleteView(generic.BulkDeleteView):
  131. queryset = Job.objects.all()
  132. filterset = filtersets.JobFilterSet
  133. table = tables.JobTable
  134. #
  135. # Config Revisions
  136. #
  137. class ConfigRevisionListView(generic.ObjectListView):
  138. queryset = ConfigRevision.objects.all()
  139. filterset = filtersets.ConfigRevisionFilterSet
  140. filterset_form = forms.ConfigRevisionFilterForm
  141. table = tables.ConfigRevisionTable
  142. @register_model_view(ConfigRevision)
  143. class ConfigRevisionView(generic.ObjectView):
  144. queryset = ConfigRevision.objects.all()
  145. class ConfigRevisionEditView(generic.ObjectEditView):
  146. queryset = ConfigRevision.objects.all()
  147. form = forms.ConfigRevisionForm
  148. @register_model_view(ConfigRevision, 'delete')
  149. class ConfigRevisionDeleteView(generic.ObjectDeleteView):
  150. queryset = ConfigRevision.objects.all()
  151. class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
  152. queryset = ConfigRevision.objects.all()
  153. filterset = filtersets.ConfigRevisionFilterSet
  154. table = tables.ConfigRevisionTable
  155. class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
  156. def get_required_permission(self):
  157. return 'core.configrevision_edit'
  158. def get(self, request, pk):
  159. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  160. # Get the current ConfigRevision
  161. config_version = get_config().version
  162. current_config = ConfigRevision.objects.filter(pk=config_version).first()
  163. params = []
  164. for param in PARAMS:
  165. params.append((
  166. param.name,
  167. current_config.data.get(param.name, None) if current_config else None,
  168. candidate_config.data.get(param.name, None)
  169. ))
  170. return render(request, 'core/configrevision_restore.html', {
  171. 'object': candidate_config,
  172. 'params': params,
  173. })
  174. def post(self, request, pk):
  175. if not request.user.has_perm('core.configrevision_edit'):
  176. return HttpResponseForbidden()
  177. candidate_config = get_object_or_404(ConfigRevision, pk=pk)
  178. candidate_config.activate()
  179. messages.success(request, f"Restored configuration revision #{pk}")
  180. return redirect(candidate_config.get_absolute_url())
  181. #
  182. # Background Tasks (RQ)
  183. #
  184. class BaseRQView(UserPassesTestMixin, View):
  185. def test_func(self):
  186. return self.request.user.is_staff
  187. class BackgroundQueueListView(TableMixin, BaseRQView):
  188. table = tables.BackgroundQueueTable
  189. def get(self, request):
  190. data = get_statistics(run_maintenance_tasks=True)["queues"]
  191. table = self.get_table(data, request, bulk_actions=False)
  192. return render(request, 'core/rq_queue_list.html', {
  193. 'table': table,
  194. })
  195. class BackgroundTaskListView(TableMixin, BaseRQView):
  196. table = tables.BackgroundTaskTable
  197. def get_table_data(self, request, queue, status):
  198. jobs = []
  199. # Call get_jobs() to returned queued tasks
  200. if status == RQJobStatus.QUEUED:
  201. return queue.get_jobs()
  202. # For other statuses, determine the registry to list (or raise a 404 for invalid statuses)
  203. try:
  204. registry_cls = {
  205. RQJobStatus.STARTED: StartedJobRegistry,
  206. RQJobStatus.DEFERRED: DeferredJobRegistry,
  207. RQJobStatus.FINISHED: FinishedJobRegistry,
  208. RQJobStatus.FAILED: FailedJobRegistry,
  209. RQJobStatus.SCHEDULED: ScheduledJobRegistry,
  210. }[status]
  211. except KeyError:
  212. raise Http404
  213. registry = registry_cls(queue.name, queue.connection)
  214. job_ids = registry.get_job_ids()
  215. if status != RQJobStatus.DEFERRED:
  216. jobs = get_jobs(queue, job_ids, registry)
  217. else:
  218. # Deferred jobs require special handling
  219. for job_id in job_ids:
  220. try:
  221. jobs.append(RQ_Job.fetch(job_id, connection=queue.connection, serializer=queue.serializer))
  222. except NoSuchJobError:
  223. pass
  224. if jobs and status == RQJobStatus.SCHEDULED:
  225. for job in jobs:
  226. job.scheduled_at = registry.get_scheduled_time(job)
  227. return jobs
  228. def get(self, request, queue_index, status):
  229. queue = get_queue_by_index(queue_index)
  230. data = self.get_table_data(request, queue, status)
  231. table = self.get_table(data, request, False)
  232. # If this is an HTMX request, return only the rendered table HTML
  233. if htmx_partial(request):
  234. return render(request, 'htmx/table.html', {
  235. 'table': table,
  236. })
  237. return render(request, 'core/rq_task_list.html', {
  238. 'table': table,
  239. 'queue': queue,
  240. 'status': status,
  241. })
  242. class BackgroundTaskView(BaseRQView):
  243. def get(self, request, job_id):
  244. # all the RQ queues should use the same connection
  245. config = QUEUES_LIST[0]
  246. try:
  247. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  248. except NoSuchJobError:
  249. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  250. queue_index = QUEUES_MAP[job.origin]
  251. queue = get_queue_by_index(queue_index)
  252. try:
  253. exc_info = job._exc_info
  254. except AttributeError:
  255. exc_info = None
  256. return render(request, 'core/rq_task.html', {
  257. 'queue': queue,
  258. 'job': job,
  259. 'queue_index': queue_index,
  260. 'dependency_id': job._dependency_id,
  261. 'exc_info': exc_info,
  262. })
  263. class BackgroundTaskDeleteView(BaseRQView):
  264. def get(self, request, job_id):
  265. if not request.htmx:
  266. return redirect(reverse('core:background_queue_list'))
  267. form = ConfirmationForm(initial=request.GET)
  268. return render(request, 'htmx/delete_form.html', {
  269. 'object_type': 'background task',
  270. 'object': job_id,
  271. 'form': form,
  272. 'form_url': reverse('core:background_task_delete', kwargs={'job_id': job_id})
  273. })
  274. def post(self, request, job_id):
  275. form = ConfirmationForm(request.POST)
  276. if form.is_valid():
  277. # all the RQ queues should use the same connection
  278. config = QUEUES_LIST[0]
  279. try:
  280. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  281. except NoSuchJobError:
  282. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  283. queue_index = QUEUES_MAP[job.origin]
  284. queue = get_queue_by_index(queue_index)
  285. # Remove job id from queue and delete the actual job
  286. queue.connection.lrem(queue.key, 0, job.id)
  287. job.delete()
  288. messages.success(request, f'Deleted job {job_id}')
  289. else:
  290. messages.error(request, f'Error deleting job: {form.errors[0]}')
  291. return redirect(reverse('core:background_queue_list'))
  292. class BackgroundTaskRequeueView(BaseRQView):
  293. def get(self, request, job_id):
  294. # all the RQ queues should use the same connection
  295. config = QUEUES_LIST[0]
  296. try:
  297. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  298. except NoSuchJobError:
  299. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  300. queue_index = QUEUES_MAP[job.origin]
  301. queue = get_queue_by_index(queue_index)
  302. requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
  303. messages.success(request, f'You have successfully requeued: {job_id}')
  304. return redirect(reverse('core:background_task', args=[job_id]))
  305. class BackgroundTaskEnqueueView(BaseRQView):
  306. def get(self, request, job_id):
  307. # all the RQ queues should use the same connection
  308. config = QUEUES_LIST[0]
  309. try:
  310. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  311. except NoSuchJobError:
  312. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  313. queue_index = QUEUES_MAP[job.origin]
  314. queue = get_queue_by_index(queue_index)
  315. try:
  316. # _enqueue_job is new in RQ 1.14, this is used to enqueue
  317. # job regardless of its dependencies
  318. queue._enqueue_job(job)
  319. except AttributeError:
  320. queue.enqueue_job(job)
  321. # Remove job from correct registry if needed
  322. if job.get_status() == RQJobStatus.DEFERRED:
  323. registry = DeferredJobRegistry(queue.name, queue.connection)
  324. registry.remove(job)
  325. elif job.get_status() == RQJobStatus.FINISHED:
  326. registry = FinishedJobRegistry(queue.name, queue.connection)
  327. registry.remove(job)
  328. elif job.get_status() == RQJobStatus.SCHEDULED:
  329. registry = ScheduledJobRegistry(queue.name, queue.connection)
  330. registry.remove(job)
  331. messages.success(request, f'You have successfully enqueued: {job_id}')
  332. return redirect(reverse('core:background_task', args=[job_id]))
  333. class BackgroundTaskStopView(BaseRQView):
  334. def get(self, request, job_id):
  335. # all the RQ queues should use the same connection
  336. config = QUEUES_LIST[0]
  337. try:
  338. job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
  339. except NoSuchJobError:
  340. raise Http404(_("Job {job_id} not found").format(job_id=job_id))
  341. queue_index = QUEUES_MAP[job.origin]
  342. queue = get_queue_by_index(queue_index)
  343. stopped, _ = stop_jobs(queue, job_id)
  344. if len(stopped) == 1:
  345. messages.success(request, f'You have successfully stopped {job_id}')
  346. else:
  347. messages.error(request, f'Failed to stop {job_id}')
  348. return redirect(reverse('core:background_task', args=[job_id]))
  349. class WorkerListView(TableMixin, BaseRQView):
  350. table = tables.WorkerTable
  351. def get_table_data(self, request, queue):
  352. clean_worker_registry(queue)
  353. all_workers = Worker.all(queue.connection)
  354. workers = [worker for worker in all_workers if queue.name in worker.queue_names()]
  355. return workers
  356. def get(self, request, queue_index):
  357. queue = get_queue_by_index(queue_index)
  358. data = self.get_table_data(request, queue)
  359. table = self.get_table(data, request, False)
  360. # If this is an HTMX request, return only the rendered table HTML
  361. if htmx_partial(request):
  362. if not request.htmx.target:
  363. table.embedded = True
  364. # Hide selection checkboxes
  365. if 'pk' in table.base_columns:
  366. table.columns.hide('pk')
  367. return render(request, 'htmx/table.html', {
  368. 'table': table,
  369. 'queue': queue,
  370. })
  371. return render(request, 'core/rq_worker_list.html', {
  372. 'table': table,
  373. 'queue': queue,
  374. })
  375. class WorkerView(BaseRQView):
  376. def get(self, request, key):
  377. # all the RQ queues should use the same connection
  378. config = QUEUES_LIST[0]
  379. worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
  380. # Convert microseconds to milliseconds
  381. worker.total_working_time = worker.total_working_time / 1000
  382. return render(request, 'core/rq_worker.html', {
  383. 'worker': worker,
  384. 'job': worker.get_current_job(),
  385. 'total_working_time': worker.total_working_time * 1000,
  386. })
  387. #
  388. # Plugins
  389. #
  390. class SystemView(UserPassesTestMixin, View):
  391. def test_func(self):
  392. return self.request.user.is_staff
  393. def get(self, request):
  394. # System stats
  395. psql_version = db_name = db_size = None
  396. try:
  397. with connection.cursor() as cursor:
  398. cursor.execute("SELECT version()")
  399. psql_version = cursor.fetchone()[0]
  400. psql_version = psql_version.split('(')[0].strip()
  401. cursor.execute("SELECT current_database()")
  402. db_name = cursor.fetchone()[0]
  403. cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
  404. db_size = cursor.fetchone()[0]
  405. except (ProgrammingError, IndexError):
  406. pass
  407. stats = {
  408. 'netbox_version': settings.VERSION,
  409. 'django_version': DJANGO_VERSION,
  410. 'python_version': platform.python_version(),
  411. 'postgresql_version': psql_version,
  412. 'database_name': db_name,
  413. 'database_size': db_size,
  414. 'rq_worker_count': Worker.count(get_connection('default')),
  415. }
  416. # Plugins
  417. plugins = [
  418. # Look up app config by package name
  419. apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
  420. ]
  421. # Configuration
  422. try:
  423. config = ConfigRevision.objects.get(pk=cache.get('config_version'))
  424. except ConfigRevision.DoesNotExist:
  425. # Fall back to using the active config data if no record is found
  426. config = ConfigRevision(data=get_config().defaults)
  427. # Raw data export
  428. if 'export' in request.GET:
  429. data = {
  430. **stats,
  431. 'plugins': {
  432. plugin.name: plugin.version for plugin in plugins
  433. },
  434. 'config': {
  435. k: config.data[k] for k in sorted(config.data)
  436. },
  437. }
  438. response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
  439. response['Content-Disposition'] = 'attachment; filename="netbox.json"'
  440. return response
  441. plugins_table = tables.PluginTable(plugins, orderable=False)
  442. plugins_table.configure(request)
  443. return render(request, 'core/system.html', {
  444. 'stats': stats,
  445. 'plugins_table': plugins_table,
  446. 'config': config,
  447. })