Coverage for extras/views.py: 63%

1048 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from datetime import datetime 

2 

3from django.contrib import messages 

4from django.contrib.auth.mixins import LoginRequiredMixin 

5from django.contrib.contenttypes.models import ContentType 

6from django.core.exceptions import ValidationError 

7from django.core.paginator import EmptyPage 

8from django.db.models import Count, Q 

9from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden 

10from django.shortcuts import get_object_or_404, redirect, render 

11from django.urls import reverse 

12from django.utils import timezone 

13from django.utils.decorators import method_decorator 

14from django.utils.http import content_disposition_header 

15from django.utils.module_loading import import_string 

16from django.utils.translation import gettext_lazy as _ 

17from django.views.decorators.cache import never_cache 

18from django.views.generic import View 

19 

20from core.choices import ManagedFileRootPathChoices 

21from core.models import Job 

22from core.object_actions import BulkSync 

23from dcim.models import Device, DeviceRole, Platform 

24from extras.choices import LogLevelChoices 

25from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm 

26from extras.dashboard.utils import get_widget_class 

27from extras.scripts import prepare_script_form 

28from extras.utils import SharedObjectViewMixin 

29from netbox.object_actions import * 

30from netbox.ui import layout 

31from netbox.ui.panels import ( 

32 CommentsPanel, 

33 ContextTablePanel, 

34 JSONPanel, 

35 TemplatePanel, 

36 TextCodePanel, 

37) 

38from netbox.views import generic 

39from netbox.views.generic.mixins import TableMixin 

40from utilities.forms import ConfirmationForm, get_field_value 

41from utilities.htmx import htmx_maybe_redirect_current_page, htmx_partial 

42from utilities.paginator import EnhancedPaginator, get_paginate_count 

43from utilities.query import count_related 

44from utilities.querydict import normalize_querydict 

45from utilities.request import copy_safe_request 

46from utilities.rqworker import any_workers_for_queue 

47from utilities.templatetags.builtins.filters import render_markdown 

48from utilities.views import ContentTypePermissionRequiredMixin, get_action_url, register_model_view 

49from virtualization.models import VirtualMachine 

50 

51from . import filtersets, forms, tables 

52from .constants import LOG_LEVEL_RANK 

53from .models import * 

54from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable 

55from .ui import panels 

56 

57# 

58# Custom fields 

59# 

60 

61 

62@register_model_view(CustomField, 'list', path='', detail=False) 

63class CustomFieldListView(generic.ObjectListView): 

64 queryset = CustomField.objects.select_related('choice_set') 

65 filterset = filtersets.CustomFieldFilterSet 

66 filterset_form = forms.CustomFieldFilterForm 

67 table = tables.CustomFieldTable 

68 

69 

70@register_model_view(CustomField) 

71class CustomFieldView(generic.ObjectView): 

72 queryset = CustomField.objects.select_related('choice_set') 

73 template_name = 'generic/object.html' 

74 layout = layout.SimpleLayout( 

75 left_panels=[ 

76 panels.CustomFieldPanel(), 

77 panels.CustomFieldBehaviorPanel(), 

78 CommentsPanel(), 

79 ], 

80 right_panels=[ 

81 panels.CustomFieldObjectTypesPanel(), 

82 panels.CustomFieldValidationPanel(), 

83 panels.CustomFieldRelatedObjectsPanel(), 

84 ], 

85 ) 

86 

87 def get_extra_context(self, request, instance): 

88 related_models = () 

89 

90 for object_type in instance.object_types.all(): 

91 related_models += ( 

92 object_type.model_class().objects.restrict(request.user, 'view').exclude( 

93 Q(**{f'custom_field_data__{instance.name}': ''}) | 

94 Q(**{f'custom_field_data__{instance.name}': None}) 

95 ), 

96 ) 

97 

98 return { 

99 'related_models': related_models 

100 } 

101 

102 

103@register_model_view(CustomField, 'add', detail=False) 

104@register_model_view(CustomField, 'edit') 

105class CustomFieldEditView(generic.ObjectEditView): 

106 queryset = CustomField.objects.select_related('choice_set') 

107 form = forms.CustomFieldForm 

108 

109 

110@register_model_view(CustomField, 'delete') 

111class CustomFieldDeleteView(generic.ObjectDeleteView): 

112 queryset = CustomField.objects.select_related('choice_set') 

113 

114 

115@register_model_view(CustomField, 'bulk_import', path='import', detail=False) 

116class CustomFieldBulkImportView(generic.BulkImportView): 

117 queryset = CustomField.objects.select_related('choice_set') 

118 model_form = forms.CustomFieldImportForm 

119 

120 

121@register_model_view(CustomField, 'bulk_edit', path='edit', detail=False) 

122class CustomFieldBulkEditView(generic.BulkEditView): 

123 queryset = CustomField.objects.select_related('choice_set') 

124 filterset = filtersets.CustomFieldFilterSet 

125 table = tables.CustomFieldTable 

126 form = forms.CustomFieldBulkEditForm 

127 

128 

129@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False) 

130class CustomFieldBulkRenameView(generic.BulkRenameView): 

131 queryset = CustomField.objects.all() 

132 filterset = filtersets.CustomFieldFilterSet 

133 

134 

135@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False) 

136class CustomFieldBulkDeleteView(generic.BulkDeleteView): 

137 queryset = CustomField.objects.select_related('choice_set') 

138 filterset = filtersets.CustomFieldFilterSet 

139 table = tables.CustomFieldTable 

140 

141 

142# 

143# Custom field choices 

144# 

145 

146@register_model_view(CustomFieldChoiceSet, 'list', path='', detail=False) 

147class CustomFieldChoiceSetListView(generic.ObjectListView): 

148 queryset = CustomFieldChoiceSet.objects.all() 

149 filterset = filtersets.CustomFieldChoiceSetFilterSet 

150 filterset_form = forms.CustomFieldChoiceSetFilterForm 

151 table = tables.CustomFieldChoiceSetTable 

152 

153 

154@register_model_view(CustomFieldChoiceSet) 

155class CustomFieldChoiceSetView(generic.ObjectView): 

156 queryset = CustomFieldChoiceSet.objects.all() 

157 template_name = 'generic/object.html' 

158 layout = layout.SimpleLayout( 

159 left_panels=[ 

160 panels.CustomFieldChoiceSetPanel(), 

161 ], 

162 right_panels=[ 

163 panels.CustomFieldChoiceSetChoicesPanel(), 

164 ], 

165 ) 

166 

167 def get_extra_context(self, request, instance): 

168 

169 # Paginate choices list 

170 per_page = get_paginate_count(request) 

171 try: 

172 page_number = request.GET.get('page', 1) 

173 except ValueError: 

174 page_number = 1 

175 choice_rows = [ 

176 { 

177 'value': value, 

178 'label': label, 

179 'color': instance.get_choice_color(value), 

180 } 

181 for value, label in instance.choices 

182 ] 

183 paginator = EnhancedPaginator(choice_rows, per_page) 

184 try: 

185 choices = paginator.page(page_number) 

186 except EmptyPage: 

187 choices = paginator.page(paginator.num_pages) 

188 

189 return { 

190 'paginator': paginator, 

191 'choices': choices, 

192 } 

193 

194 

195@register_model_view(CustomFieldChoiceSet, 'add', detail=False) 

196@register_model_view(CustomFieldChoiceSet, 'edit') 

197class CustomFieldChoiceSetEditView(generic.ObjectEditView): 

198 queryset = CustomFieldChoiceSet.objects.all() 

199 form = forms.CustomFieldChoiceSetForm 

200 

201 

202@register_model_view(CustomFieldChoiceSet, 'delete') 

203class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView): 

204 queryset = CustomFieldChoiceSet.objects.all() 

205 

206 

207@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False) 

208class CustomFieldChoiceSetBulkImportView(generic.BulkImportView): 

209 queryset = CustomFieldChoiceSet.objects.all() 

210 model_form = forms.CustomFieldChoiceSetImportForm 

211 

212 

213@register_model_view(CustomFieldChoiceSet, 'bulk_edit', path='edit', detail=False) 

214class CustomFieldChoiceSetBulkEditView(generic.BulkEditView): 

215 queryset = CustomFieldChoiceSet.objects.all() 

216 filterset = filtersets.CustomFieldChoiceSetFilterSet 

217 table = tables.CustomFieldChoiceSetTable 

218 form = forms.CustomFieldChoiceSetBulkEditForm 

219 

220 

221@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False) 

222class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView): 

223 queryset = CustomFieldChoiceSet.objects.all() 

224 filterset = filtersets.CustomFieldChoiceSetFilterSet 

225 

226 

227@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False) 

228class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): 

229 queryset = CustomFieldChoiceSet.objects.all() 

230 filterset = filtersets.CustomFieldChoiceSetFilterSet 

231 table = tables.CustomFieldChoiceSetTable 

232 

233 

234# 

235# Custom links 

236# 

237 

238@register_model_view(CustomLink, 'list', path='', detail=False) 

239class CustomLinkListView(generic.ObjectListView): 

240 queryset = CustomLink.objects.all() 

241 filterset = filtersets.CustomLinkFilterSet 

242 filterset_form = forms.CustomLinkFilterForm 

243 table = tables.CustomLinkTable 

244 

245 

246@register_model_view(CustomLink) 

247class CustomLinkView(generic.ObjectView): 

248 queryset = CustomLink.objects.all() 

249 template_name = 'generic/object.html' 

250 layout = layout.SimpleLayout( 

251 left_panels=[ 

252 panels.CustomLinkPanel(), 

253 panels.ObjectTypesPanel(title=_('Assigned Models')), 

254 ], 

255 right_panels=[ 

256 TextCodePanel('link_text', title=_('Link Text')), 

257 TextCodePanel('link_url', title=_('Link URL')), 

258 ], 

259 ) 

260 

261 

262@register_model_view(CustomLink, 'add', detail=False) 

263@register_model_view(CustomLink, 'edit') 

264class CustomLinkEditView(generic.ObjectEditView): 

265 queryset = CustomLink.objects.all() 

266 form = forms.CustomLinkForm 

267 

268 

269@register_model_view(CustomLink, 'delete') 

270class CustomLinkDeleteView(generic.ObjectDeleteView): 

271 queryset = CustomLink.objects.all() 

272 

273 

274@register_model_view(CustomLink, 'bulk_import', path='import', detail=False) 

275class CustomLinkBulkImportView(generic.BulkImportView): 

276 queryset = CustomLink.objects.all() 

277 model_form = forms.CustomLinkImportForm 

278 

279 

280@register_model_view(CustomLink, 'bulk_edit', path='edit', detail=False) 

281class CustomLinkBulkEditView(generic.BulkEditView): 

282 queryset = CustomLink.objects.all() 

283 filterset = filtersets.CustomLinkFilterSet 

284 table = tables.CustomLinkTable 

285 form = forms.CustomLinkBulkEditForm 

286 

287 

288@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False) 

289class CustomLinkBulkRenameView(generic.BulkRenameView): 

290 queryset = CustomLink.objects.all() 

291 filterset = filtersets.CustomLinkFilterSet 

292 

293 

294@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False) 

295class CustomLinkBulkDeleteView(generic.BulkDeleteView): 

296 queryset = CustomLink.objects.all() 

297 filterset = filtersets.CustomLinkFilterSet 

298 table = tables.CustomLinkTable 

299 

300 

301# 

302# Export templates 

303# 

304 

305@register_model_view(ExportTemplate, 'list', path='', detail=False) 

306class ExportTemplateListView(generic.ObjectListView): 

307 queryset = ExportTemplate.objects.all() 

308 filterset = filtersets.ExportTemplateFilterSet 

309 filterset_form = forms.ExportTemplateFilterForm 

310 table = tables.ExportTemplateTable 

311 actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete) 

312 

313 

314@register_model_view(ExportTemplate) 

315@method_decorator(never_cache, name='dispatch') 

316class ExportTemplateView(generic.ObjectView): 

317 queryset = ExportTemplate.objects.all() 

318 template_name = 'generic/object.html' 

319 layout = layout.SimpleLayout( 

320 left_panels=[ 

321 panels.ExportTemplatePanel(), 

322 TemplatePanel('core/inc/datafile_panel.html'), 

323 ], 

324 right_panels=[ 

325 panels.ObjectTypesPanel(title=_('Assigned Models')), 

326 JSONPanel('environment_params', title=_('Environment Parameters')), 

327 ], 

328 bottom_panels=[ 

329 TextCodePanel('template_code', title=_('Template'), show_sync_warning=True), 

330 ], 

331 ) 

332 

333 

334@register_model_view(ExportTemplate, 'add', detail=False) 

335@register_model_view(ExportTemplate, 'edit') 

336class ExportTemplateEditView(generic.ObjectEditView): 

337 queryset = ExportTemplate.objects.all() 

338 form = forms.ExportTemplateForm 

339 

340 

341@register_model_view(ExportTemplate, 'delete') 

342class ExportTemplateDeleteView(generic.ObjectDeleteView): 

343 queryset = ExportTemplate.objects.all() 

344 

345 

346@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False) 

347class ExportTemplateBulkImportView(generic.BulkImportView): 

348 queryset = ExportTemplate.objects.all() 

349 model_form = forms.ExportTemplateImportForm 

350 

351 

352@register_model_view(ExportTemplate, 'bulk_edit', path='edit', detail=False) 

353class ExportTemplateBulkEditView(generic.BulkEditView): 

354 queryset = ExportTemplate.objects.all() 

355 filterset = filtersets.ExportTemplateFilterSet 

356 table = tables.ExportTemplateTable 

357 form = forms.ExportTemplateBulkEditForm 

358 

359 

360@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False) 

361class ExportTemplateBulkRenameView(generic.BulkRenameView): 

362 queryset = ExportTemplate.objects.all() 

363 filterset = filtersets.ExportTemplateFilterSet 

364 

365 

366@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False) 

367class ExportTemplateBulkDeleteView(generic.BulkDeleteView): 

368 queryset = ExportTemplate.objects.all() 

369 filterset = filtersets.ExportTemplateFilterSet 

370 table = tables.ExportTemplateTable 

371 

372 

373@register_model_view(ExportTemplate, 'bulk_sync', path='sync', detail=False) 

374class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView): 

375 queryset = ExportTemplate.objects.all() 

376 

377 

378# 

379# Saved filters 

380# 

381 

382@register_model_view(SavedFilter, 'list', path='', detail=False) 

383class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView): 

384 queryset = SavedFilter.objects.all() 

385 filterset = filtersets.SavedFilterFilterSet 

386 filterset_form = forms.SavedFilterFilterForm 

387 table = tables.SavedFilterTable 

388 

389 

390@register_model_view(SavedFilter) 

391class SavedFilterView(SharedObjectViewMixin, generic.ObjectView): 

392 queryset = SavedFilter.objects.all() 

393 template_name = 'generic/object.html' 

394 layout = layout.SimpleLayout( 

395 left_panels=[ 

396 panels.SavedFilterPanel(), 

397 panels.SavedFilterObjectTypesPanel(), 

398 ], 

399 right_panels=[ 

400 JSONPanel('parameters', title=_('Parameters')), 

401 ], 

402 ) 

403 

404 

405@register_model_view(SavedFilter, 'add', detail=False) 

406@register_model_view(SavedFilter, 'edit') 

407class SavedFilterEditView(SharedObjectViewMixin, generic.ObjectEditView): 

408 queryset = SavedFilter.objects.all() 

409 form = forms.SavedFilterForm 

410 

411 def alter_object(self, obj, request, url_args, url_kwargs): 

412 if not obj.pk: 

413 obj.user = request.user 

414 return obj 

415 

416 

417@register_model_view(SavedFilter, 'delete') 

418class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView): 

419 queryset = SavedFilter.objects.all() 

420 

421 

422@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False) 

423class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView): 

424 queryset = SavedFilter.objects.all() 

425 model_form = forms.SavedFilterImportForm 

426 

427 

428@register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False) 

429class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView): 

430 queryset = SavedFilter.objects.all() 

431 filterset = filtersets.SavedFilterFilterSet 

432 table = tables.SavedFilterTable 

433 form = forms.SavedFilterBulkEditForm 

434 

435 

436@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False) 

437class SavedFilterBulkRenameView(generic.BulkRenameView): 

438 queryset = SavedFilter.objects.all() 

439 filterset = filtersets.SavedFilterFilterSet 

440 

441 

442@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False) 

443class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): 

444 queryset = SavedFilter.objects.all() 

445 filterset = filtersets.SavedFilterFilterSet 

446 table = tables.SavedFilterTable 

447 

448 

449# 

450# Table configs 

451# 

452 

453@register_model_view(TableConfig, 'list', path='', detail=False) 

454class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView): 

455 queryset = TableConfig.objects.all() 

456 filterset = filtersets.TableConfigFilterSet 

457 filterset_form = forms.TableConfigFilterForm 

458 table = tables.TableConfigTable 

459 actions = (BulkExport, BulkEdit, BulkRename, BulkDelete) 

460 

461 

462@register_model_view(TableConfig) 

463class TableConfigView(SharedObjectViewMixin, generic.ObjectView): 

464 queryset = TableConfig.objects.all() 

465 template_name = 'generic/object.html' 

466 layout = layout.SimpleLayout( 

467 left_panels=[ 

468 panels.TableConfigPanel(), 

469 ], 

470 right_panels=[ 

471 panels.TableConfigColumnsPanel(), 

472 panels.TableConfigOrderingPanel(), 

473 ], 

474 ) 

475 

476 def get_extra_context(self, request, instance): 

477 table = instance.table_class([]) 

478 return { 

479 'columns': dict(table.columns.items()), 

480 } 

481 

482 

483@register_model_view(TableConfig, 'add', detail=False) 

484@register_model_view(TableConfig, 'edit') 

485class TableConfigEditView(SharedObjectViewMixin, generic.ObjectEditView): 

486 queryset = TableConfig.objects.all() 

487 form = forms.TableConfigForm 

488 template_name = 'extras/tableconfig_edit.html' 

489 

490 def get(self, request, *args, **kwargs): 

491 # The add view requires the object_type & table parameters from the source table view 

492 if not kwargs and not (request.GET.get('object_type') and request.GET.get('table')): 

493 messages.warning(request, _('Table configurations must be created from an object list view.')) 

494 return redirect('home') 

495 

496 return super().get(request, *args, **kwargs) 

497 

498 def alter_object(self, obj, request, url_args, url_kwargs): 

499 if not obj.pk: 

500 obj.user = request.user 

501 return obj 

502 

503 

504@register_model_view(TableConfig, 'delete') 

505class TableConfigDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView): 

506 queryset = TableConfig.objects.all() 

507 

508 

509@register_model_view(TableConfig, 'bulk_edit', path='edit', detail=False) 

510class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView): 

511 queryset = TableConfig.objects.all() 

512 filterset = filtersets.TableConfigFilterSet 

513 table = tables.TableConfigTable 

514 form = forms.TableConfigBulkEditForm 

515 

516 

517@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False) 

518class TableConfigBulkRenameView(generic.BulkRenameView): 

519 queryset = TableConfig.objects.all() 

520 filterset = filtersets.TableConfigFilterSet 

521 

522 

523@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False) 

524class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): 

525 queryset = TableConfig.objects.all() 

526 filterset = filtersets.TableConfigFilterSet 

527 table = tables.TableConfigTable 

528 

529 

530# 

531# Bookmarks 

532# 

533 

534@register_model_view(Bookmark, 'add', detail=False) 

535class BookmarkCreateView(generic.ObjectEditView): 

536 form = forms.BookmarkForm 

537 

538 def get_queryset(self, request): 

539 return Bookmark.objects.filter(user=request.user) 

540 

541 def alter_object(self, obj, request, url_args, url_kwargs): 

542 obj.user = request.user 

543 return obj 

544 

545 

546@register_model_view(Bookmark, 'delete') 

547class BookmarkDeleteView(generic.ObjectDeleteView): 

548 

549 def get_queryset(self, request): 

550 return Bookmark.objects.filter(user=request.user) 

551 

552 

553@register_model_view(Bookmark, 'bulk_delete', path='delete', detail=False) 

554class BookmarkBulkDeleteView(generic.BulkDeleteView): 

555 table = tables.BookmarkTable 

556 

557 def get_queryset(self, request): 

558 return Bookmark.objects.filter(user=request.user) 

559 

560 

561# 

562# Notification groups 

563# 

564 

565@register_model_view(NotificationGroup, 'list', path='', detail=False) 

566class NotificationGroupListView(generic.ObjectListView): 

567 queryset = NotificationGroup.objects.all() 

568 filterset = filtersets.NotificationGroupFilterSet 

569 filterset_form = forms.NotificationGroupFilterForm 

570 table = tables.NotificationGroupTable 

571 

572 

573@register_model_view(NotificationGroup) 

574class NotificationGroupView(generic.ObjectView): 

575 queryset = NotificationGroup.objects.all() 

576 template_name = 'generic/object.html' 

577 layout = layout.SimpleLayout( 

578 left_panels=[ 

579 panels.NotificationGroupPanel(), 

580 ], 

581 right_panels=[ 

582 panels.NotificationGroupGroupsPanel(), 

583 panels.NotificationGroupUsersPanel(), 

584 ], 

585 ) 

586 

587 

588@register_model_view(NotificationGroup, 'add', detail=False) 

589@register_model_view(NotificationGroup, 'edit') 

590class NotificationGroupEditView(generic.ObjectEditView): 

591 queryset = NotificationGroup.objects.all() 

592 form = forms.NotificationGroupForm 

593 

594 

595@register_model_view(NotificationGroup, 'delete') 

596class NotificationGroupDeleteView(generic.ObjectDeleteView): 

597 queryset = NotificationGroup.objects.all() 

598 

599 

600@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False) 

601class NotificationGroupBulkImportView(generic.BulkImportView): 

602 queryset = NotificationGroup.objects.all() 

603 model_form = forms.NotificationGroupImportForm 

604 

605 

606@register_model_view(NotificationGroup, 'bulk_edit', path='edit', detail=False) 

607class NotificationGroupBulkEditView(generic.BulkEditView): 

608 queryset = NotificationGroup.objects.all() 

609 filterset = filtersets.NotificationGroupFilterSet 

610 table = tables.NotificationGroupTable 

611 form = forms.NotificationGroupBulkEditForm 

612 

613 

614@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False) 

615class NotificationGroupBulkRenameView(generic.BulkRenameView): 

616 queryset = NotificationGroup.objects.all() 

617 filterset = filtersets.NotificationGroupFilterSet 

618 

619 

620@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False) 

621class NotificationGroupBulkDeleteView(generic.BulkDeleteView): 

622 queryset = NotificationGroup.objects.all() 

623 filterset = filtersets.NotificationGroupFilterSet 

624 table = tables.NotificationGroupTable 

625 

626 

627# 

628# Notifications 

629# 

630 

631class NotificationsView(LoginRequiredMixin, View): 

632 """ 

633 HTMX-only user-specific notifications list. 

634 """ 

635 def get(self, request): 

636 return render(request, 'htmx/notifications.html', { 

637 'notifications': request.user.notifications.unread()[:10], 

638 'total_count': request.user.notifications.count(), 

639 'unread_count': request.user.notifications.unread().count(), 

640 }) 

641 

642 

643@register_model_view(Notification, 'read') 

644class NotificationReadView(LoginRequiredMixin, View): 

645 """ 

646 Mark the Notification read and redirect the user to its attached object. 

647 """ 

648 

649 def get(self, request, pk): 

650 # Mark the Notification as read 

651 notification = get_object_or_404(request.user.notifications, pk=pk) 

652 notification.read = timezone.now() 

653 notification.save() 

654 

655 # Redirect to the object if it has a URL (deleted objects will not) 

656 if hasattr(notification.object, 'get_absolute_url'): 

657 return redirect(notification.object.get_absolute_url()) 

658 

659 return redirect('account:notifications') 

660 

661 

662@register_model_view(Notification, name='dismiss_all', path='dismiss-all', detail=False) 

663class NotificationDismissAllView(LoginRequiredMixin, View): 

664 """ 

665 Convenience view to clear all *unread* notifications for the current user. 

666 """ 

667 

668 def get(self, request): 

669 request.user.notifications.unread().delete() 

670 if htmx_partial(request): 

671 # If a user is currently on the notification page, redirect there (full repaint) 

672 redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True) 

673 if redirect_resp: 

674 return redirect_resp 

675 

676 return render(request, 'htmx/notifications.html', { 

677 'notifications': request.user.notifications.unread()[:10], 

678 'total_count': request.user.notifications.count(), 

679 'unread_count': request.user.notifications.unread().count(), 

680 }) 

681 return redirect('account:notifications') 

682 

683 

684@register_model_view(Notification, 'dismiss') 

685class NotificationDismissView(LoginRequiredMixin, View): 

686 """ 

687 A convenience view which allows deleting notifications with one click. 

688 """ 

689 

690 def get(self, request, pk): 

691 notification = get_object_or_404(request.user.notifications, pk=pk) 

692 notification.delete() 

693 

694 if htmx_partial(request): 

695 # If a user is currently on the notification page, redirect there (full repaint) 

696 redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True) 

697 if redirect_resp: 

698 return redirect_resp 

699 

700 return render(request, 'htmx/notifications.html', { 

701 'notifications': request.user.notifications.unread()[:10], 

702 'total_count': request.user.notifications.count(), 

703 'unread_count': request.user.notifications.unread().count(), 

704 }) 

705 

706 return redirect('account:notifications') 

707 

708 

709@register_model_view(Notification, 'delete') 

710class NotificationDeleteView(generic.ObjectDeleteView): 

711 

712 def get_queryset(self, request): 

713 return Notification.objects.filter(user=request.user) 

714 

715 

716@register_model_view(Notification, 'bulk_delete', path='delete', detail=False) 

717class NotificationBulkDeleteView(generic.BulkDeleteView): 

718 table = tables.NotificationTable 

719 

720 def get_queryset(self, request): 

721 return Notification.objects.filter(user=request.user) 

722 

723 

724# 

725# Subscriptions 

726# 

727 

728@register_model_view(Subscription, 'add', detail=False) 

729class SubscriptionCreateView(generic.ObjectEditView): 

730 form = forms.SubscriptionForm 

731 

732 def get_queryset(self, request): 

733 return Subscription.objects.filter(user=request.user) 

734 

735 def alter_object(self, obj, request, url_args, url_kwargs): 

736 obj.user = request.user 

737 return obj 

738 

739 

740@register_model_view(Subscription, 'delete') 

741class SubscriptionDeleteView(generic.ObjectDeleteView): 

742 

743 def get_queryset(self, request): 

744 return Subscription.objects.filter(user=request.user) 

745 

746 

747@register_model_view(Subscription, 'bulk_delete', path='delete', detail=False) 

748class SubscriptionBulkDeleteView(generic.BulkDeleteView): 

749 table = tables.SubscriptionTable 

750 

751 def get_queryset(self, request): 

752 return Subscription.objects.filter(user=request.user) 

753 

754 

755# 

756# Webhooks 

757# 

758 

759@register_model_view(Webhook, 'list', path='', detail=False) 

760class WebhookListView(generic.ObjectListView): 

761 queryset = Webhook.objects.all() 

762 filterset = filtersets.WebhookFilterSet 

763 filterset_form = forms.WebhookFilterForm 

764 table = tables.WebhookTable 

765 

766 

767@register_model_view(Webhook) 

768class WebhookView(generic.ObjectView): 

769 queryset = Webhook.objects.all() 

770 template_name = 'generic/object.html' 

771 layout = layout.SimpleLayout( 

772 left_panels=[ 

773 panels.WebhookPanel(), 

774 panels.WebhookHTTPPanel(), 

775 panels.WebhookSSLPanel(), 

776 ], 

777 right_panels=[ 

778 TextCodePanel('additional_headers', title=_('Additional Headers')), 

779 TextCodePanel('body_template', title=_('Body Template')), 

780 panels.CustomFieldsPanel(), 

781 panels.TagsPanel(), 

782 ], 

783 ) 

784 

785 

786@register_model_view(Webhook, 'add', detail=False) 

787@register_model_view(Webhook, 'edit') 

788class WebhookEditView(generic.ObjectEditView): 

789 queryset = Webhook.objects.all() 

790 form = forms.WebhookForm 

791 

792 

793@register_model_view(Webhook, 'delete') 

794class WebhookDeleteView(generic.ObjectDeleteView): 

795 queryset = Webhook.objects.all() 

796 

797 

798@register_model_view(Webhook, 'bulk_import', path='import', detail=False) 

799class WebhookBulkImportView(generic.BulkImportView): 

800 queryset = Webhook.objects.all() 

801 model_form = forms.WebhookImportForm 

802 

803 

804@register_model_view(Webhook, 'bulk_edit', path='edit', detail=False) 

805class WebhookBulkEditView(generic.BulkEditView): 

806 queryset = Webhook.objects.all() 

807 filterset = filtersets.WebhookFilterSet 

808 table = tables.WebhookTable 

809 form = forms.WebhookBulkEditForm 

810 

811 

812@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False) 

813class WebhookBulkRenameView(generic.BulkRenameView): 

814 queryset = Webhook.objects.all() 

815 filterset = filtersets.WebhookFilterSet 

816 

817 

818@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False) 

819class WebhookBulkDeleteView(generic.BulkDeleteView): 

820 queryset = Webhook.objects.all() 

821 filterset = filtersets.WebhookFilterSet 

822 table = tables.WebhookTable 

823 

824 

825# 

826# Event Rules 

827# 

828 

829@register_model_view(EventRule, 'list', path='', detail=False) 

830class EventRuleListView(generic.ObjectListView): 

831 queryset = EventRule.objects.all() 

832 filterset = filtersets.EventRuleFilterSet 

833 filterset_form = forms.EventRuleFilterForm 

834 table = tables.EventRuleTable 

835 

836 

837@register_model_view(EventRule) 

838class EventRuleView(generic.ObjectView): 

839 queryset = EventRule.objects.all() 

840 template_name = 'generic/object.html' 

841 layout = layout.SimpleLayout( 

842 left_panels=[ 

843 panels.EventRulePanel(), 

844 panels.ObjectTypesPanel(), 

845 panels.EventRuleEventTypesPanel(), 

846 ], 

847 right_panels=[ 

848 JSONPanel('conditions', title=_('Conditions')), 

849 panels.EventRuleActionPanel(), 

850 panels.CustomFieldsPanel(), 

851 panels.TagsPanel(), 

852 ], 

853 ) 

854 

855 

856@register_model_view(EventRule, 'add', detail=False) 

857@register_model_view(EventRule, 'edit') 

858class EventRuleEditView(generic.ObjectEditView): 

859 queryset = EventRule.objects.all() 

860 form = forms.EventRuleForm 

861 

862 

863@register_model_view(EventRule, 'delete') 

864class EventRuleDeleteView(generic.ObjectDeleteView): 

865 queryset = EventRule.objects.all() 

866 

867 

868@register_model_view(EventRule, 'bulk_import', path='import', detail=False) 

869class EventRuleBulkImportView(generic.BulkImportView): 

870 queryset = EventRule.objects.all() 

871 model_form = forms.EventRuleImportForm 

872 

873 

874@register_model_view(EventRule, 'bulk_edit', path='edit', detail=False) 

875class EventRuleBulkEditView(generic.BulkEditView): 

876 queryset = EventRule.objects.all() 

877 filterset = filtersets.EventRuleFilterSet 

878 table = tables.EventRuleTable 

879 form = forms.EventRuleBulkEditForm 

880 

881 

882@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False) 

883class EventRuleBulkRenameView(generic.BulkRenameView): 

884 queryset = EventRule.objects.all() 

885 filterset = filtersets.EventRuleFilterSet 

886 

887 

888@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False) 

889class EventRuleBulkDeleteView(generic.BulkDeleteView): 

890 queryset = EventRule.objects.all() 

891 filterset = filtersets.EventRuleFilterSet 

892 table = tables.EventRuleTable 

893 

894 

895# 

896# Tags 

897# 

898 

899@register_model_view(Tag, 'list', path='', detail=False) 

900class TagListView(generic.ObjectListView): 

901 queryset = Tag.objects.annotate( 

902 items=count_related(TaggedItem, 'tag') 

903 ) 

904 filterset = filtersets.TagFilterSet 

905 filterset_form = forms.TagFilterForm 

906 table = tables.TagTable 

907 

908 

909@register_model_view(Tag) 

910class TagView(generic.ObjectView): 

911 queryset = Tag.objects.all() 

912 template_name = 'generic/object.html' 

913 layout = layout.SimpleLayout( 

914 left_panels=[ 

915 panels.TagPanel(), 

916 ], 

917 right_panels=[ 

918 panels.TagObjectTypesPanel(), 

919 panels.TagItemTypesPanel(), 

920 ], 

921 bottom_panels=[ 

922 ContextTablePanel('taggeditem_table', title=_('Tagged Objects')), 

923 ], 

924 ) 

925 

926 def get_extra_context(self, request, instance): 

927 tagged_items = TaggedItem.objects.filter(tag=instance) 

928 taggeditem_table = tables.TaggedItemTable( 

929 data=tagged_items, 

930 orderable=False 

931 ) 

932 taggeditem_table.configure(request) 

933 

934 object_types = [ 

935 { 

936 'content_type': ContentType.objects.get(pk=ti['content_type']), 

937 'item_count': ti['item_count'] 

938 } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk')) 

939 ] 

940 

941 return { 

942 'taggeditem_table': taggeditem_table, 

943 'tagged_item_count': tagged_items.count(), 

944 'object_types': object_types, 

945 } 

946 

947 

948@register_model_view(Tag, 'add', detail=False) 

949@register_model_view(Tag, 'edit') 

950class TagEditView(generic.ObjectEditView): 

951 queryset = Tag.objects.all() 

952 form = forms.TagForm 

953 

954 

955@register_model_view(Tag, 'delete') 

956class TagDeleteView(generic.ObjectDeleteView): 

957 queryset = Tag.objects.all() 

958 

959 

960@register_model_view(Tag, 'bulk_import', path='import', detail=False) 

961class TagBulkImportView(generic.BulkImportView): 

962 queryset = Tag.objects.all() 

963 model_form = forms.TagImportForm 

964 

965 

966@register_model_view(Tag, 'bulk_edit', path='edit', detail=False) 

967class TagBulkEditView(generic.BulkEditView): 

968 queryset = Tag.objects.annotate( 

969 items=count_related(TaggedItem, 'tag') 

970 ) 

971 table = tables.TagTable 

972 form = forms.TagBulkEditForm 

973 

974 

975@register_model_view(Tag, 'bulk_rename', path='rename', detail=False) 

976class TagBulkRenameView(generic.BulkRenameView): 

977 queryset = Tag.objects.all() 

978 

979 

980@register_model_view(Tag, 'bulk_delete', path='delete', detail=False) 

981class TagBulkDeleteView(generic.BulkDeleteView): 

982 queryset = Tag.objects.annotate( 

983 items=count_related(TaggedItem, 'tag') 

984 ) 

985 table = tables.TagTable 

986 

987 

988# 

989# Config context profiles 

990# 

991 

992@register_model_view(ConfigContextProfile, 'list', path='', detail=False) 

993class ConfigContextProfileListView(generic.ObjectListView): 

994 queryset = ConfigContextProfile.objects.all() 

995 filterset = filtersets.ConfigContextProfileFilterSet 

996 filterset_form = forms.ConfigContextProfileFilterForm 

997 table = tables.ConfigContextProfileTable 

998 actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete) 

999 

1000 

1001@register_model_view(ConfigContextProfile) 

1002@method_decorator(never_cache, name='dispatch') 

1003class ConfigContextProfileView(generic.ObjectView): 

1004 queryset = ConfigContextProfile.objects.all() 

1005 template_name = 'generic/object.html' 

1006 layout = layout.SimpleLayout( 

1007 left_panels=[ 

1008 panels.ConfigContextProfilePanel(), 

1009 TemplatePanel('core/inc/datafile_panel.html'), 

1010 panels.CustomFieldsPanel(), 

1011 panels.TagsPanel(), 

1012 CommentsPanel(), 

1013 ], 

1014 right_panels=[ 

1015 JSONPanel('schema', title=_('JSON Schema')), 

1016 ], 

1017 ) 

1018 

1019 

1020@register_model_view(ConfigContextProfile, 'add', detail=False) 

1021@register_model_view(ConfigContextProfile, 'edit') 

1022class ConfigContextProfileEditView(generic.ObjectEditView): 

1023 queryset = ConfigContextProfile.objects.all() 

1024 form = forms.ConfigContextProfileForm 

1025 

1026 

1027@register_model_view(ConfigContextProfile, 'delete') 

1028class ConfigContextProfileDeleteView(generic.ObjectDeleteView): 

1029 queryset = ConfigContextProfile.objects.all() 

1030 

1031 

1032@register_model_view(ConfigContextProfile, 'bulk_import', path='import', detail=False) 

1033class ConfigContextProfileBulkImportView(generic.BulkImportView): 

1034 queryset = ConfigContextProfile.objects.all() 

1035 model_form = forms.ConfigContextProfileImportForm 

1036 

1037 

1038@register_model_view(ConfigContextProfile, 'bulk_edit', path='edit', detail=False) 

1039class ConfigContextProfileBulkEditView(generic.BulkEditView): 

1040 queryset = ConfigContextProfile.objects.all() 

1041 filterset = filtersets.ConfigContextProfileFilterSet 

1042 table = tables.ConfigContextProfileTable 

1043 form = forms.ConfigContextProfileBulkEditForm 

1044 

1045 

1046@register_model_view(ConfigContextProfile, 'bulk_rename', path='rename', detail=False) 

1047class ConfigContextProfileBulkRenameView(generic.BulkRenameView): 

1048 queryset = ConfigContextProfile.objects.all() 

1049 filterset = filtersets.ConfigContextProfileFilterSet 

1050 

1051 

1052@register_model_view(ConfigContextProfile, 'bulk_delete', path='delete', detail=False) 

1053class ConfigContextProfileBulkDeleteView(generic.BulkDeleteView): 

1054 queryset = ConfigContextProfile.objects.all() 

1055 filterset = filtersets.ConfigContextProfileFilterSet 

1056 table = tables.ConfigContextProfileTable 

1057 

1058 

1059@register_model_view(ConfigContextProfile, 'bulk_sync', path='sync', detail=False) 

1060class ConfigContextProfileBulkSyncDataView(generic.BulkSyncDataView): 

1061 queryset = ConfigContextProfile.objects.all() 

1062 

1063 

1064# 

1065# Config contexts 

1066# 

1067 

1068@register_model_view(ConfigContext, 'list', path='', detail=False) 

1069class ConfigContextListView(generic.ObjectListView): 

1070 queryset = ConfigContext.objects.all() 

1071 filterset = filtersets.ConfigContextFilterSet 

1072 filterset_form = forms.ConfigContextFilterForm 

1073 table = tables.ConfigContextTable 

1074 actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete) 

1075 

1076 

1077@register_model_view(ConfigContext) 

1078@method_decorator(never_cache, name='dispatch') 

1079class ConfigContextView(generic.ObjectView): 

1080 queryset = ConfigContext.objects.all() 

1081 template_name = 'generic/object.html' 

1082 layout = layout.SimpleLayout( 

1083 left_panels=[ 

1084 panels.ConfigContextPanel(), 

1085 TemplatePanel('core/inc/datafile_panel.html'), 

1086 panels.ConfigContextAssignmentPanel(), 

1087 ], 

1088 right_panels=[ 

1089 TemplatePanel('extras/panels/configcontext_data.html'), 

1090 ], 

1091 ) 

1092 

1093 def get_extra_context(self, request, instance): 

1094 # Gather assigned objects for parsing in the template 

1095 assigned_objects = ( 

1096 ('Regions', instance.regions.all), 

1097 ('Site Groups', instance.site_groups.all), 

1098 ('Sites', instance.sites.all), 

1099 ('Locations', instance.locations.all), 

1100 ('Device Types', instance.device_types.all), 

1101 ('Roles', instance.roles.all), 

1102 ('Platforms', instance.platforms.all), 

1103 ('Cluster Types', instance.cluster_types.all), 

1104 ('Cluster Groups', instance.cluster_groups.all), 

1105 ('Clusters', instance.clusters.all), 

1106 ('Tenant Groups', instance.tenant_groups.all), 

1107 ('Tenants', instance.tenants.all), 

1108 ('Tags', instance.tags.all), 

1109 ) 

1110 

1111 # Determine user's preferred output format 

1112 if request.GET.get('format') in ['json', 'yaml']: 

1113 format = request.GET.get('format') 

1114 if request.user.is_authenticated: 

1115 request.user.config.set('data_format', format, commit=True) 

1116 elif request.user.is_authenticated: 

1117 format = request.user.config.get('data_format', 'json') 

1118 else: 

1119 format = 'json' 

1120 

1121 return { 

1122 'assigned_objects': assigned_objects, 

1123 'format': format, 

1124 } 

1125 

1126 

1127@register_model_view(ConfigContext, 'add', detail=False) 

1128@register_model_view(ConfigContext, 'edit') 

1129class ConfigContextEditView(generic.ObjectEditView): 

1130 queryset = ConfigContext.objects.all() 

1131 form = forms.ConfigContextForm 

1132 

1133 

1134@register_model_view(ConfigContext, 'delete') 

1135class ConfigContextDeleteView(generic.ObjectDeleteView): 

1136 queryset = ConfigContext.objects.all() 

1137 

1138 

1139@register_model_view(ConfigContext, 'bulk_edit', path='edit', detail=False) 

1140class ConfigContextBulkEditView(generic.BulkEditView): 

1141 queryset = ConfigContext.objects.all() 

1142 filterset = filtersets.ConfigContextFilterSet 

1143 table = tables.ConfigContextTable 

1144 form = forms.ConfigContextBulkEditForm 

1145 

1146 

1147@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False) 

1148class ConfigContextBulkRenameView(generic.BulkRenameView): 

1149 queryset = ConfigContext.objects.all() 

1150 filterset = filtersets.ConfigContextFilterSet 

1151 

1152 

1153@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False) 

1154class ConfigContextBulkDeleteView(generic.BulkDeleteView): 

1155 queryset = ConfigContext.objects.all() 

1156 filterset = filtersets.ConfigContextFilterSet 

1157 table = tables.ConfigContextTable 

1158 

1159 

1160@register_model_view(ConfigContext, 'bulk_sync', path='sync', detail=False) 

1161class ConfigContextBulkSyncDataView(generic.BulkSyncDataView): 

1162 queryset = ConfigContext.objects.all() 

1163 

1164 

1165@method_decorator(never_cache, name='dispatch') 

1166class ObjectConfigContextView(generic.ObjectView): 

1167 base_template = None 

1168 template_name = 'extras/object_configcontext.html' 

1169 

1170 def get_extra_context(self, request, instance): 

1171 source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(instance) 

1172 

1173 # Determine user's preferred output format 

1174 if request.GET.get('format') in ['json', 'yaml']: 

1175 format = request.GET.get('format') 

1176 if request.user.is_authenticated: 

1177 request.user.config.set('data_format', format, commit=True) 

1178 elif request.user.is_authenticated: 

1179 format = request.user.config.get('data_format', 'json') 

1180 else: 

1181 format = 'json' 

1182 

1183 return { 

1184 'rendered_context': instance.get_config_context(), 

1185 'source_contexts': source_contexts, 

1186 'format': format, 

1187 'base_template': self.base_template, 

1188 } 

1189 

1190 

1191# 

1192# Config templates 

1193# 

1194 

1195@register_model_view(ConfigTemplate, 'list', path='', detail=False) 

1196class ConfigTemplateListView(generic.ObjectListView): 

1197 queryset = ConfigTemplate.objects.annotate( 

1198 device_count=count_related(Device, 'config_template'), 

1199 vm_count=count_related(VirtualMachine, 'config_template'), 

1200 role_count=count_related(DeviceRole, 'config_template'), 

1201 platform_count=count_related(Platform, 'config_template'), 

1202 ) 

1203 filterset = filtersets.ConfigTemplateFilterSet 

1204 filterset_form = forms.ConfigTemplateFilterForm 

1205 table = tables.ConfigTemplateTable 

1206 actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete) 

1207 

1208 

1209@register_model_view(ConfigTemplate) 

1210@method_decorator(never_cache, name='dispatch') 

1211class ConfigTemplateView(generic.ObjectView): 

1212 queryset = ConfigTemplate.objects.all() 

1213 template_name = 'generic/object.html' 

1214 layout = layout.SimpleLayout( 

1215 left_panels=[ 

1216 panels.ConfigTemplatePanel(), 

1217 panels.TagsPanel(), 

1218 ], 

1219 right_panels=[ 

1220 JSONPanel('environment_params', title=_('Environment Parameters')), 

1221 ], 

1222 bottom_panels=[ 

1223 TextCodePanel('template_code', title=_('Template'), show_sync_warning=True), 

1224 ], 

1225 ) 

1226 

1227 

1228@register_model_view(ConfigTemplate, 'add', detail=False) 

1229@register_model_view(ConfigTemplate, 'edit') 

1230class ConfigTemplateEditView(generic.ObjectEditView): 

1231 queryset = ConfigTemplate.objects.all() 

1232 form = forms.ConfigTemplateForm 

1233 

1234 

1235@register_model_view(ConfigTemplate, 'delete') 

1236class ConfigTemplateDeleteView(generic.ObjectDeleteView): 

1237 queryset = ConfigTemplate.objects.all() 

1238 

1239 

1240@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False) 

1241class ConfigTemplateBulkImportView(generic.BulkImportView): 

1242 queryset = ConfigTemplate.objects.all() 

1243 model_form = forms.ConfigTemplateImportForm 

1244 

1245 

1246@register_model_view(ConfigTemplate, 'bulk_edit', path='edit', detail=False) 

1247class ConfigTemplateBulkEditView(generic.BulkEditView): 

1248 queryset = ConfigTemplate.objects.all() 

1249 filterset = filtersets.ConfigTemplateFilterSet 

1250 table = tables.ConfigTemplateTable 

1251 form = forms.ConfigTemplateBulkEditForm 

1252 

1253 

1254@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False) 

1255class ConfigTemplateBulkRenameView(generic.BulkRenameView): 

1256 queryset = ConfigTemplate.objects.all() 

1257 filterset = filtersets.ConfigTemplateFilterSet 

1258 

1259 

1260@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False) 

1261class ConfigTemplateBulkDeleteView(generic.BulkDeleteView): 

1262 queryset = ConfigTemplate.objects.all() 

1263 filterset = filtersets.ConfigTemplateFilterSet 

1264 table = tables.ConfigTemplateTable 

1265 

1266 

1267@register_model_view(ConfigTemplate, 'bulk_sync', path='sync', detail=False) 

1268class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): 

1269 queryset = ConfigTemplate.objects.all() 

1270 

1271 

1272@method_decorator(never_cache, name='dispatch') 

1273class ObjectRenderConfigView(generic.ObjectView): 

1274 base_template = None 

1275 template_name = 'extras/object_render_config.html' 

1276 

1277 def get(self, request, **kwargs): 

1278 instance = self.get_object(**kwargs) 

1279 context = self.get_extra_context(request, instance) 

1280 

1281 # If a direct export has been requested, return the rendered template content as a 

1282 # downloadable file. 

1283 if request.GET.get('export'): 

1284 content = context['rendered_config'] or context['error_message'] 

1285 response = HttpResponse(content, content_type='text') 

1286 filename = f"{instance.name or 'config'}.txt" 

1287 response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename) 

1288 return response 

1289 

1290 return render( 

1291 request, 

1292 self.get_template_name(), 

1293 { 

1294 'object': instance, 

1295 'tab': self.tab, 

1296 **context, 

1297 }, 

1298 ) 

1299 

1300 def get_extra_context_data(self, request, instance): 

1301 return { 

1302 f'{instance._meta.model_name}': instance, 

1303 } 

1304 

1305 def get_extra_context(self, request, instance): 

1306 # Compile context data 

1307 context_data = instance.get_config_context() 

1308 context_data.update(self.get_extra_context_data(request, instance)) 

1309 

1310 # Check for an optional config_template_id override in the query params 

1311 config_template = None 

1312 error_message = '' 

1313 if config_template_id := request.GET.get('config_template_id'): 

1314 try: 

1315 config_template = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id) 

1316 except (ConfigTemplate.DoesNotExist, ValueError): 

1317 error_message = _("Config template with ID {id} not found.").format(id=config_template_id) 

1318 else: 

1319 config_template = instance.get_config_template() 

1320 

1321 # Render the config template 

1322 rendered_config = None 

1323 if config_template: 

1324 try: 

1325 rendered_config = config_template.render(context=context_data) 

1326 except Exception as e: 

1327 error_message = config_template.format_render_error(e) 

1328 

1329 return { 

1330 'base_template': self.base_template, 

1331 'config_template': config_template, 

1332 'context_data': context_data, 

1333 'rendered_config': rendered_config, 

1334 'error_message': error_message, 

1335 } 

1336 

1337 

1338# 

1339# Image attachments 

1340# 

1341 

1342@register_model_view(ImageAttachment, 'list', path='', detail=False) 

1343class ImageAttachmentListView(generic.ObjectListView): 

1344 queryset = ImageAttachment.objects.all() 

1345 filterset = filtersets.ImageAttachmentFilterSet 

1346 filterset_form = forms.ImageAttachmentFilterForm 

1347 table = tables.ImageAttachmentTable 

1348 actions = (BulkExport, BulkEdit, BulkRename, BulkDelete) 

1349 

1350 

1351@register_model_view(ImageAttachment) 

1352class ImageAttachmentView(generic.ObjectView): 

1353 queryset = ImageAttachment.objects.all() 

1354 template_name = 'generic/object.html' 

1355 layout = layout.SimpleLayout( 

1356 left_panels=[ 

1357 panels.ImageAttachmentPanel(), 

1358 ], 

1359 right_panels=[ 

1360 panels.ImageAttachmentFilePanel(), 

1361 ], 

1362 bottom_panels=[ 

1363 panels.ImageAttachmentImagePanel(), 

1364 ], 

1365 ) 

1366 

1367 

1368@register_model_view(ImageAttachment, 'add', detail=False) 

1369@register_model_view(ImageAttachment, 'edit') 

1370class ImageAttachmentEditView(generic.ObjectEditView): 

1371 queryset = ImageAttachment.objects.all() 

1372 form = forms.ImageAttachmentForm 

1373 

1374 def alter_object(self, instance, request, args, kwargs): 

1375 if not instance.pk: 

1376 # Assign the parent object based on URL kwargs 

1377 object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type')) 

1378 instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id')) 

1379 return instance 

1380 

1381 def get_extra_addanother_params(self, request): 

1382 return { 

1383 'object_type': request.GET.get('object_type'), 

1384 'object_id': request.GET.get('object_id'), 

1385 } 

1386 

1387 

1388@register_model_view(ImageAttachment, 'delete') 

1389class ImageAttachmentDeleteView(generic.ObjectDeleteView): 

1390 queryset = ImageAttachment.objects.all() 

1391 

1392 

1393@register_model_view(ImageAttachment, 'bulk_edit', path='edit', detail=False) 

1394class ImageAttachmentBulkEditView(generic.BulkEditView): 

1395 queryset = ImageAttachment.objects.all() 

1396 filterset = filtersets.ImageAttachmentFilterSet 

1397 table = tables.ImageAttachmentTable 

1398 form = forms.ImageAttachmentBulkEditForm 

1399 

1400 

1401@register_model_view(ImageAttachment, 'bulk_rename', path='rename', detail=False) 

1402class ImageAttachmentBulkRenameView(generic.BulkRenameView): 

1403 queryset = ImageAttachment.objects.all() 

1404 filterset = filtersets.ImageAttachmentFilterSet 

1405 

1406 

1407@register_model_view(ImageAttachment, 'bulk_delete', path='delete', detail=False) 

1408class ImageAttachmentBulkDeleteView(generic.BulkDeleteView): 

1409 queryset = ImageAttachment.objects.all() 

1410 filterset = filtersets.ImageAttachmentFilterSet 

1411 table = tables.ImageAttachmentTable 

1412 

1413 

1414# 

1415# Journal entries 

1416# 

1417 

1418@register_model_view(JournalEntry, 'list', path='', detail=False) 

1419class JournalEntryListView(generic.ObjectListView): 

1420 queryset = JournalEntry.objects.all() 

1421 filterset = filtersets.JournalEntryFilterSet 

1422 filterset_form = forms.JournalEntryFilterForm 

1423 table = tables.JournalEntryTable 

1424 actions = (BulkImport, BulkEdit, BulkDelete) 

1425 

1426 

1427@register_model_view(JournalEntry) 

1428class JournalEntryView(generic.ObjectView): 

1429 queryset = JournalEntry.objects.all() 

1430 layout = layout.SimpleLayout( 

1431 left_panels=[ 

1432 panels.JournalEntryPanel(), 

1433 panels.CustomFieldsPanel(), 

1434 panels.TagsPanel(), 

1435 ], 

1436 right_panels=[ 

1437 CommentsPanel(), 

1438 ], 

1439 ) 

1440 

1441 

1442@register_model_view(JournalEntry, 'add', detail=False) 

1443@register_model_view(JournalEntry, 'edit') 

1444class JournalEntryEditView(generic.ObjectEditView): 

1445 queryset = JournalEntry.objects.all() 

1446 form = forms.JournalEntryForm 

1447 

1448 def alter_object(self, obj, request, args, kwargs): 

1449 if not obj.pk: 

1450 obj.created_by = request.user 

1451 return obj 

1452 

1453 def get_return_url(self, request, instance): 

1454 if not instance.assigned_object: 

1455 return reverse('extras:journalentry_list') 

1456 obj = instance.assigned_object 

1457 return get_action_url(obj, action='journal', kwargs={'pk': obj.pk}) 

1458 

1459 

1460@register_model_view(JournalEntry, 'delete') 

1461class JournalEntryDeleteView(generic.ObjectDeleteView): 

1462 queryset = JournalEntry.objects.all() 

1463 

1464 def get_return_url(self, request, instance): 

1465 obj = instance.assigned_object 

1466 return get_action_url(obj, action='journal', kwargs={'pk': obj.pk}) 

1467 

1468 

1469@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False) 

1470class JournalEntryBulkImportView(generic.BulkImportView): 

1471 queryset = JournalEntry.objects.all() 

1472 model_form = forms.JournalEntryImportForm 

1473 

1474 

1475@register_model_view(JournalEntry, 'bulk_edit', path='edit', detail=False) 

1476class JournalEntryBulkEditView(generic.BulkEditView): 

1477 queryset = JournalEntry.objects.all() 

1478 filterset = filtersets.JournalEntryFilterSet 

1479 table = tables.JournalEntryTable 

1480 form = forms.JournalEntryBulkEditForm 

1481 

1482 

1483@register_model_view(JournalEntry, 'bulk_delete', path='delete', detail=False) 

1484class JournalEntryBulkDeleteView(generic.BulkDeleteView): 

1485 queryset = JournalEntry.objects.all() 

1486 filterset = filtersets.JournalEntryFilterSet 

1487 table = tables.JournalEntryTable 

1488 

1489 

1490# 

1491# Dashboard & widgets 

1492# 

1493 

1494class DashboardResetView(LoginRequiredMixin, View): 

1495 template_name = 'extras/dashboard/reset.html' 

1496 

1497 def get(self, request): 

1498 get_object_or_404(Dashboard.objects.all(), user=request.user) 

1499 form = ConfirmationForm() 

1500 

1501 return render(request, self.template_name, { 

1502 'form': form, 

1503 'return_url': reverse('home'), 

1504 }) 

1505 

1506 def post(self, request): 

1507 dashboard = get_object_or_404(Dashboard.objects.all(), user=request.user) 

1508 form = ConfirmationForm(request.POST) 

1509 

1510 if form.is_valid(): 

1511 dashboard.delete() 

1512 messages.success(request, _("Your dashboard has been reset.")) 

1513 return redirect(reverse('home')) 

1514 

1515 return render(request, self.template_name, { 

1516 'form': form, 

1517 'return_url': reverse('home'), 

1518 }) 

1519 

1520 

1521class DashboardWidgetAddView(LoginRequiredMixin, View): 

1522 template_name = 'extras/dashboard/widget_add.html' 

1523 

1524 def get(self, request): 

1525 if not request.htmx: 

1526 return redirect('home') 

1527 

1528 initial = { 

1529 'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget', 

1530 } 

1531 widget_form = DashboardWidgetAddForm(initial=initial) 

1532 widget_name = get_field_value(widget_form, 'widget_class') 

1533 widget_class = get_widget_class(widget_name) 

1534 config_form = widget_class.ConfigForm(initial=widget_class.default_config, prefix='config') 

1535 

1536 return render(request, self.template_name, { 

1537 'widget_class': widget_class, 

1538 'widget_form': widget_form, 

1539 'config_form': config_form, 

1540 }) 

1541 

1542 def post(self, request): 

1543 widget_form = DashboardWidgetAddForm(request.POST) 

1544 config_form = None 

1545 widget_class = None 

1546 

1547 if widget_form.is_valid(): 

1548 widget_class = get_widget_class(widget_form.cleaned_data['widget_class']) 

1549 config_form = widget_class.ConfigForm(request.POST, prefix='config') 

1550 

1551 if config_form.is_valid(): 

1552 data = widget_form.cleaned_data 

1553 data.pop('widget_class') 

1554 data['config'] = config_form.cleaned_data 

1555 widget = widget_class(**data) 

1556 request.user.dashboard.add_widget(widget) 

1557 request.user.dashboard.save() 

1558 messages.success(request, _('Added widget: ') + str(widget.id)) 

1559 

1560 return HttpResponse(headers={ 

1561 'HX-Redirect': reverse('home'), 

1562 }) 

1563 

1564 return render(request, self.template_name, { 

1565 'widget_class': widget_class, 

1566 'widget_form': widget_form, 

1567 'config_form': config_form, 

1568 }) 

1569 

1570 

1571class DashboardWidgetConfigView(LoginRequiredMixin, View): 

1572 template_name = 'extras/dashboard/widget_config.html' 

1573 

1574 def get(self, request, id): 

1575 if not request.htmx: 

1576 return redirect('home') 

1577 

1578 widget = request.user.dashboard.get_widget(id) 

1579 widget_form = DashboardWidgetForm(initial=widget.form_data) 

1580 config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config') 

1581 

1582 return render(request, self.template_name, { 

1583 'widget_class': widget.__class__, 

1584 'widget_form': widget_form, 

1585 'config_form': config_form, 

1586 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id}) 

1587 }) 

1588 

1589 def post(self, request, id): 

1590 widget = request.user.dashboard.get_widget(id) 

1591 widget_form = DashboardWidgetForm(request.POST) 

1592 config_form = widget.ConfigForm(request.POST, prefix='config') 

1593 

1594 if widget_form.is_valid() and config_form.is_valid(): 

1595 data = widget_form.cleaned_data 

1596 data['config'] = config_form.cleaned_data 

1597 request.user.dashboard.config[str(id)].update(data) 

1598 request.user.dashboard.save() 

1599 messages.success(request, _('Updated widget: ') + str(widget.id)) 

1600 

1601 return HttpResponse(headers={ 

1602 'HX-Redirect': reverse('home'), 

1603 }) 

1604 

1605 return render(request, self.template_name, { 

1606 'widget_form': widget_form, 

1607 'config_form': config_form, 

1608 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id}) 

1609 }) 

1610 

1611 

1612class DashboardWidgetDeleteView(LoginRequiredMixin, View): 

1613 template_name = 'generic/object_delete.html' 

1614 

1615 def get(self, request, id): 

1616 if not request.htmx: 

1617 return redirect('home') 

1618 

1619 widget = request.user.dashboard.get_widget(id) 

1620 form = ConfirmationForm(initial=request.GET) 

1621 

1622 return render(request, 'htmx/delete_form.html', { 

1623 'object_type': widget.__class__.__name__, 

1624 'object': widget, 

1625 'form': form, 

1626 'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id}) 

1627 }) 

1628 

1629 def post(self, request, id): 

1630 form = ConfirmationForm(request.POST) 

1631 

1632 if form.is_valid(): 

1633 request.user.dashboard.delete_widget(id) 

1634 request.user.dashboard.save() 

1635 messages.success(request, _('Deleted widget: ') + str(id)) 

1636 else: 

1637 messages.error(request, _('Error deleting widget: ') + str(form.errors[0])) 

1638 

1639 return redirect(reverse('home')) 

1640 

1641 

1642# 

1643# Scripts 

1644# 

1645 

1646@register_model_view(ScriptModule, 'edit') 

1647class ScriptModuleCreateView(generic.ObjectEditView): 

1648 queryset = ScriptModule.objects.all() 

1649 form = forms.ScriptFileForm 

1650 default_return_url = 'extras:script_list' 

1651 

1652 def alter_object(self, obj, *args, **kwargs): 

1653 obj.file_root = ManagedFileRootPathChoices.SCRIPTS 

1654 return obj 

1655 

1656 

1657@register_model_view(ScriptModule, 'delete') 

1658class ScriptModuleDeleteView(generic.ObjectDeleteView): 

1659 queryset = ScriptModule.objects.all() 

1660 default_return_url = 'extras:script_list' 

1661 

1662 

1663class ScriptListView(ContentTypePermissionRequiredMixin, View): 

1664 

1665 def get_required_permission(self): 

1666 return 'extras.view_script' 

1667 

1668 def get(self, request): 

1669 available_scripts = Script.objects.restrict(request.user) 

1670 module_ids = {s.module_id for s in available_scripts} 

1671 script_modules = ScriptModule.objects.restrict(request.user).filter(pk__in=module_ids).prefetch_related( 

1672 'data_source', 'data_file', 

1673 ) 

1674 

1675 context = { 

1676 'model': ScriptModule, 

1677 'script_modules': script_modules, 

1678 'available_scripts': available_scripts, 

1679 } 

1680 

1681 # Use partial template for dashboard widgets 

1682 template_name = 'extras/script_list.html' 

1683 if request.GET.get('embedded'): 

1684 template_name = 'extras/inc/script_list_content.html' 

1685 context['embedded'] = True 

1686 

1687 return render(request, template_name, context) 

1688 

1689 

1690class BaseScriptView(generic.ObjectView): 

1691 queryset = Script.objects.all() 

1692 

1693 def get_object(self, **kwargs): 

1694 if pk := kwargs.get('pk', False): 

1695 return get_object_or_404(self.queryset, pk=pk) 

1696 if (module := kwargs.get('module')) and (name := kwargs.get('name', False)): 

1697 return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name) 

1698 raise Http404 

1699 

1700 def _get_script_class(self, script): 

1701 """ 

1702 Return an instance of the Script's Python class 

1703 """ 

1704 if script_class := script.python_class: 

1705 return script_class() 

1706 return None 

1707 

1708 

1709class ScriptView(BaseScriptView): 

1710 

1711 def get(self, request, **kwargs): 

1712 script = self.get_object(**kwargs) 

1713 script_class = self._get_script_class(script) 

1714 if not script_class: 

1715 return render(request, 'extras/script.html', { 

1716 'object': script, 

1717 'script': script, 

1718 }) 

1719 

1720 form = script_class.as_form(initial=normalize_querydict(request.GET)) 

1721 

1722 return render(request, 'extras/script.html', { 

1723 'object': script, 

1724 'script': script, 

1725 'script_class': script_class, 

1726 'form': form, 

1727 'job_count': script.jobs.count(), 

1728 }) 

1729 

1730 def post(self, request, **kwargs): 

1731 script = self.get_object(**kwargs) 

1732 

1733 if not request.user.has_perm('extras.run_script', obj=script): 

1734 return HttpResponseForbidden() 

1735 

1736 script_class = self._get_script_class(script) 

1737 if not script_class: 

1738 return render(request, 'extras/script.html', { 

1739 'object': script, 

1740 'script': script, 

1741 }) 

1742 

1743 form = prepare_script_form(script_class, request.POST, request.FILES) 

1744 

1745 # Allow execution only if RQ worker process is running 

1746 if not any_workers_for_queue('default'): 

1747 messages.error(request, _("Unable to run script: RQ worker process not running.")) 

1748 elif form.is_valid(): 

1749 ScriptJob = import_string("extras.jobs.ScriptJob") 

1750 try: 

1751 job = ScriptJob.enqueue( 

1752 instance=script, 

1753 user=request.user, 

1754 schedule_at=form.cleaned_data.pop('_schedule_at'), 

1755 interval=form.cleaned_data.pop('_interval'), 

1756 notifications=form.cleaned_data.pop('_notifications'), 

1757 data=form.cleaned_data, 

1758 request=copy_safe_request(request), 

1759 job_timeout=script.python_class.job_timeout, 

1760 commit=form.cleaned_data.pop('_commit'), 

1761 ) 

1762 except ValidationError as e: 

1763 # The script's Meta configuration is invalid (see #22872). Surface it as a form error rather than 

1764 # allowing the exception to bubble up as an HTTP 500. 

1765 for msg in e.messages: 

1766 messages.error(request, _("Unable to run script: {error}").format(error=msg)) 

1767 else: 

1768 return redirect('extras:script_result', job_pk=job.pk) 

1769 else: 

1770 fieldset_fields = {field for _, fields in script_class.get_fieldsets() for field in fields} 

1771 hidden_errors = { 

1772 field: errors for field, errors in form.errors.items() 

1773 if field not in fieldset_fields 

1774 } 

1775 if hidden_errors: 

1776 error_msg = '; '.join(f"{field}: {', '.join(errors)}" for field, errors in hidden_errors.items()) 

1777 messages.error(request, error_msg) 

1778 

1779 return render(request, 'extras/script.html', { 

1780 'object': script, 

1781 'script': script, 

1782 'script_class': script.python_class(), 

1783 'form': form, 

1784 'job_count': script.jobs.count(), 

1785 }) 

1786 

1787 

1788class ScriptSourceView(BaseScriptView): 

1789 queryset = Script.objects.all() 

1790 

1791 def get(self, request, **kwargs): 

1792 script = self.get_object(**kwargs) 

1793 script_class = self._get_script_class(script) 

1794 

1795 return render(request, 'extras/script/source.html', { 

1796 'script': script, 

1797 'script_class': script_class, 

1798 'job_count': script.jobs.count(), 

1799 'tab': 'source', 

1800 }) 

1801 

1802 

1803class ScriptJobsView(BaseScriptView): 

1804 queryset = Script.objects.all() 

1805 

1806 def get(self, request, **kwargs): 

1807 script = self.get_object(**kwargs) 

1808 

1809 jobs_table = ScriptJobTable(data=script.jobs.all(), orderable=False) 

1810 jobs_table.configure(request) 

1811 

1812 return render(request, 'extras/script/jobs.html', { 

1813 'script': script, 

1814 'table': jobs_table, 

1815 'job_count': script.jobs.count(), 

1816 'tab': 'jobs', 

1817 }) 

1818 

1819 

1820class ScriptResultView(TableMixin, generic.ObjectView): 

1821 queryset = Job.objects.all() 

1822 

1823 def get_required_permission(self): 

1824 return 'extras.view_script' 

1825 

1826 def get_queryset(self, request): 

1827 return Job.objects.restrict(request.user, 'view') 

1828 

1829 def get_table(self, job, request, bulk_actions=True): 

1830 data = [] 

1831 tests = None 

1832 table = None 

1833 index = 0 

1834 

1835 try: 

1836 log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)] 

1837 except KeyError: 

1838 log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO] 

1839 if job.data: 

1840 if 'log' in job.data: 

1841 if 'tests' in job.data: 

1842 tests = job.data['tests'] 

1843 

1844 for log in job.data['log']: 

1845 log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO) 

1846 if log_level >= log_threshold: 

1847 index += 1 

1848 result = { 

1849 'index': index, 

1850 'time': datetime.fromisoformat(log.get('time')), 

1851 'status': log.get('status'), 

1852 'message': log.get('message'), 

1853 'object': log.get('obj'), 

1854 'url': log.get('url'), 

1855 } 

1856 data.append(result) 

1857 

1858 table = ScriptResultsTable(data) 

1859 table.configure(request) 

1860 else: 

1861 # for legacy reports 

1862 tests = job.data 

1863 

1864 if tests: 

1865 for method, test_data in tests.items(): 

1866 if 'log' in test_data: 

1867 for time, status, obj, url, message in test_data['log']: 

1868 log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO) 

1869 if log_level >= log_threshold: 

1870 index += 1 

1871 result = { 

1872 'index': index, 

1873 'method': method, 

1874 'time': time, 

1875 'status': status, 

1876 'object': obj, 

1877 'url': url, 

1878 'message': message, 

1879 } 

1880 data.append(result) 

1881 

1882 table = ReportResultsTable(data) 

1883 table.configure(request) 

1884 

1885 return table 

1886 

1887 def get(self, request, **kwargs): 

1888 table = None 

1889 job = get_object_or_404(self.queryset, pk=kwargs.get('job_pk')) 

1890 

1891 # If a direct export output has been requested, return the job data content as a 

1892 # downloadable file. 

1893 if job.completed and request.GET.get('export') == 'output': 

1894 content = (job.data.get("output") or "").encode() 

1895 response = HttpResponse(content, content_type='text') 

1896 filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt" 

1897 response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename) 

1898 return response 

1899 

1900 if job.completed: 

1901 table = self.get_table(job, request, bulk_actions=False) 

1902 

1903 log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO) 

1904 if log_threshold not in LOG_LEVEL_RANK: 

1905 log_threshold = LogLevelChoices.LOG_INFO 

1906 

1907 context = { 

1908 'script': job.object, 

1909 'job': job, 

1910 'table': table, 

1911 'log_levels': dict(LogLevelChoices), 

1912 'log_threshold': log_threshold, 

1913 } 

1914 

1915 if job.data and 'log' in job.data: 

1916 # Script 

1917 context['tests'] = job.data.get('tests', {}) 

1918 elif job.data: 

1919 # Legacy Report 

1920 context['tests'] = { 

1921 name: data for name, data in job.data.items() 

1922 if name.startswith('test_') 

1923 } 

1924 

1925 # If this is an HTMX request, return only the result HTML 

1926 if htmx_partial(request): 

1927 if request.GET.get('log'): 

1928 # If log=True, render only the log table 

1929 return render(request, 'htmx/table.html', context) 

1930 response = render(request, 'extras/htmx/script_result.html', context) 

1931 if job.completed or not job.started: 

1932 response.status_code = 286 

1933 return response 

1934 

1935 return render(request, 'extras/script_result.html', context) 

1936 

1937 

1938# 

1939# Markdown 

1940# 

1941 

1942class RenderMarkdownView(LoginRequiredMixin, View): 

1943 

1944 def post(self, request): 

1945 form = forms.RenderMarkdownForm(request.POST) 

1946 if not form.is_valid(): 

1947 HttpResponseBadRequest() 

1948 rendered = render_markdown(form.cleaned_data['text']) 

1949 

1950 return HttpResponse(rendered)