views.py 22 KB

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