views.py 23 KB

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