views.py 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373
  1. import logging
  2. import platform
  3. import re
  4. import sys
  5. from copy import deepcopy
  6. from django.conf import settings
  7. from django.contrib import messages
  8. from django.contrib.auth.decorators import login_required
  9. from django.contrib.contenttypes.models import ContentType
  10. from django.contrib.auth.mixins import AccessMixin
  11. from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
  12. from django.db import transaction, IntegrityError
  13. from django.db.models import ManyToManyField, ProtectedError
  14. from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
  15. from django.http import HttpResponse, HttpResponseServerError
  16. from django.shortcuts import get_object_or_404, redirect, render
  17. from django.template import loader
  18. from django.template.exceptions import TemplateDoesNotExist
  19. from django.urls import reverse
  20. from django.urls.exceptions import NoReverseMatch
  21. from django.utils.decorators import method_decorator
  22. from django.utils.html import escape
  23. from django.utils.http import is_safe_url
  24. from django.utils.safestring import mark_safe
  25. from django.views.decorators.csrf import requires_csrf_token
  26. from django.views.defaults import ERROR_500_TEMPLATE_NAME
  27. from django.views.generic import View
  28. from django_tables2 import RequestConfig
  29. from extras.models import CustomField, ExportTemplate
  30. from utilities.exceptions import AbortTransaction
  31. from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
  32. from utilities.permissions import get_permission_for_model, resolve_permission
  33. from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
  34. from .error_handlers import handle_protectederror
  35. from .forms import ConfirmationForm, ImportForm
  36. from .paginator import EnhancedPaginator, get_paginate_count
  37. #
  38. # Mixins
  39. #
  40. class ContentTypePermissionRequiredMixin(AccessMixin):
  41. """
  42. Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
  43. This is related to ObjectPermissionRequiredMixin, except that is does not enforce object-level permissions,
  44. and fits within NetBox's custom permission enforcement system.
  45. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  46. derived from the object type
  47. """
  48. additional_permissions = list()
  49. def get_required_permission(self):
  50. """
  51. Return the specific permission necessary to perform the requested action on an object.
  52. """
  53. raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
  54. def has_permission(self):
  55. user = self.request.user
  56. permission_required = self.get_required_permission()
  57. # Check that the user has been granted the required permission(s).
  58. if user.has_perms((permission_required, *self.additional_permissions)):
  59. return True
  60. return False
  61. def dispatch(self, request, *args, **kwargs):
  62. if not self.has_permission():
  63. return self.handle_no_permission()
  64. return super().dispatch(request, *args, **kwargs)
  65. class ObjectPermissionRequiredMixin(AccessMixin):
  66. """
  67. Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
  68. permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
  69. to return only those objects on which the user is permitted to perform the specified action.
  70. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  71. derived from the object type
  72. """
  73. additional_permissions = list()
  74. def get_required_permission(self):
  75. """
  76. Return the specific permission necessary to perform the requested action on an object.
  77. """
  78. raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
  79. def has_permission(self):
  80. user = self.request.user
  81. permission_required = self.get_required_permission()
  82. # Check that the user has been granted the required permission(s).
  83. if user.has_perms((permission_required, *self.additional_permissions)):
  84. # Update the view's QuerySet to filter only the permitted objects
  85. action = resolve_permission(permission_required)[1]
  86. self.queryset = self.queryset.restrict(user, action)
  87. return True
  88. return False
  89. def dispatch(self, request, *args, **kwargs):
  90. if not hasattr(self, 'queryset'):
  91. raise ImproperlyConfigured(
  92. '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
  93. 'a base queryset'.format(self.__class__.__name__)
  94. )
  95. if not self.has_permission():
  96. return self.handle_no_permission()
  97. return super().dispatch(request, *args, **kwargs)
  98. class GetReturnURLMixin:
  99. """
  100. Provides logic for determining where a user should be redirected after processing a form.
  101. """
  102. default_return_url = None
  103. def get_return_url(self, request, obj=None):
  104. # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
  105. # considered safe.
  106. query_param = request.GET.get('return_url') or request.POST.get('return_url')
  107. if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()):
  108. return query_param
  109. # Next, check if the object being modified (if any) has an absolute URL.
  110. if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
  111. return obj.get_absolute_url()
  112. # Fall back to the default URL (if specified) for the view.
  113. if self.default_return_url is not None:
  114. return reverse(self.default_return_url)
  115. # Attempt to dynamically resolve the list view for the object
  116. if hasattr(self, 'queryset'):
  117. model_opts = self.queryset.model._meta
  118. try:
  119. return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list')
  120. except NoReverseMatch:
  121. pass
  122. # If all else fails, return home. Ideally this should never happen.
  123. return reverse('home')
  124. #
  125. # Generic views
  126. #
  127. class ObjectView(ObjectPermissionRequiredMixin, View):
  128. """
  129. Retrieve a single object for display.
  130. queryset: The base queryset for retrieving the object.
  131. """
  132. queryset = None
  133. def get_required_permission(self):
  134. return get_permission_for_model(self.queryset.model, 'view')
  135. def get_template_name(self):
  136. """
  137. Return self.template_name if set. Otherwise, resolve the template path by model app_label and name.
  138. """
  139. if hasattr(self, 'template_name'):
  140. return self.template_name
  141. model_opts = self.queryset.model._meta
  142. return f'{model_opts.app_label}/{model_opts.model_name}.html'
  143. def get(self, request, pk):
  144. """
  145. Generic GET handler for accessing an object by PK
  146. """
  147. instance = get_object_or_404(self.queryset, pk=pk)
  148. return render(request, self.get_template_name(), {
  149. 'instance': instance,
  150. })
  151. class ObjectListView(ObjectPermissionRequiredMixin, View):
  152. """
  153. List a series of objects.
  154. queryset: The queryset of objects to display
  155. filter: A django-filter FilterSet that is applied to the queryset
  156. filter_form: The form used to render filter options
  157. table: The django-tables2 Table used to render the objects list
  158. template_name: The name of the template
  159. """
  160. queryset = None
  161. filterset = None
  162. filterset_form = None
  163. table = None
  164. template_name = 'utilities/obj_list.html'
  165. action_buttons = ('add', 'import', 'export')
  166. def get_required_permission(self):
  167. return get_permission_for_model(self.queryset.model, 'view')
  168. def queryset_to_yaml(self):
  169. """
  170. Export the queryset of objects as concatenated YAML documents.
  171. """
  172. yaml_data = [obj.to_yaml() for obj in self.queryset]
  173. return '---\n'.join(yaml_data)
  174. def queryset_to_csv(self):
  175. """
  176. Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
  177. """
  178. csv_data = []
  179. custom_fields = []
  180. # Start with the column headers
  181. headers = self.queryset.model.csv_headers.copy()
  182. # Add custom field headers, if any
  183. if hasattr(self.queryset.model, 'custom_field_data'):
  184. for custom_field in CustomField.objects.get_for_model(self.queryset.model):
  185. headers.append(custom_field.name)
  186. custom_fields.append(custom_field.name)
  187. csv_data.append(','.join(headers))
  188. # Iterate through the queryset appending each object
  189. for obj in self.queryset:
  190. data = obj.to_csv()
  191. for custom_field in custom_fields:
  192. data += (obj.cf.get(custom_field, ''),)
  193. csv_data.append(csv_format(data))
  194. return '\n'.join(csv_data)
  195. def get(self, request):
  196. model = self.queryset.model
  197. content_type = ContentType.objects.get_for_model(model)
  198. if self.filterset:
  199. self.queryset = self.filterset(request.GET, self.queryset).qs
  200. # Check for export template rendering
  201. if request.GET.get('export'):
  202. et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
  203. try:
  204. return et.render_to_response(self.queryset)
  205. except Exception as e:
  206. messages.error(
  207. request,
  208. "There was an error rendering the selected export template ({}): {}".format(
  209. et.name, e
  210. )
  211. )
  212. # Check for YAML export support
  213. elif 'export' in request.GET and hasattr(model, 'to_yaml'):
  214. response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
  215. filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
  216. response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
  217. return response
  218. # Fall back to built-in CSV formatting if export requested but no template specified
  219. elif 'export' in request.GET and hasattr(model, 'to_csv'):
  220. response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
  221. filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
  222. response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
  223. return response
  224. # Compile a dictionary indicating which permissions are available to the current user for this model
  225. permissions = {}
  226. for action in ('add', 'change', 'delete', 'view'):
  227. perm_name = get_permission_for_model(model, action)
  228. permissions[action] = request.user.has_perm(perm_name)
  229. # Construct the objects table
  230. table = self.table(self.queryset, user=request.user)
  231. if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
  232. table.columns.show('pk')
  233. # Apply the request context
  234. paginate = {
  235. 'paginator_class': EnhancedPaginator,
  236. 'per_page': get_paginate_count(request)
  237. }
  238. RequestConfig(request, paginate).configure(table)
  239. context = {
  240. 'content_type': content_type,
  241. 'table': table,
  242. 'permissions': permissions,
  243. 'action_buttons': self.action_buttons,
  244. 'table_config_form': TableConfigForm(table=table),
  245. 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
  246. }
  247. context.update(self.extra_context())
  248. return render(request, self.template_name, context)
  249. def extra_context(self):
  250. return {}
  251. class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  252. """
  253. Create or edit a single object.
  254. queryset: The base queryset for the object being modified
  255. model_form: The form used to create or edit the object
  256. template_name: The name of the template
  257. """
  258. queryset = None
  259. model_form = None
  260. template_name = 'utilities/obj_edit.html'
  261. def get_required_permission(self):
  262. # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
  263. # we are modifying an existing object or creating a new one.
  264. return get_permission_for_model(self.queryset.model, self._permission_action)
  265. def get_object(self, kwargs):
  266. # Look up an existing object by slug or PK, if provided.
  267. if 'slug' in kwargs:
  268. return get_object_or_404(self.queryset, slug=kwargs['slug'])
  269. elif 'pk' in kwargs:
  270. return get_object_or_404(self.queryset, pk=kwargs['pk'])
  271. # Otherwise, return a new instance.
  272. return self.queryset.model()
  273. def alter_obj(self, obj, request, url_args, url_kwargs):
  274. # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
  275. # given some parameter from the request URL.
  276. return obj
  277. def dispatch(self, request, *args, **kwargs):
  278. # Determine required permission based on whether we are editing an existing object
  279. self._permission_action = 'change' if kwargs else 'add'
  280. return super().dispatch(request, *args, **kwargs)
  281. def get(self, request, *args, **kwargs):
  282. obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
  283. initial_data = normalize_querydict(request.GET)
  284. form = self.model_form(instance=obj, initial=initial_data)
  285. restrict_form_fields(form, request.user)
  286. return render(request, self.template_name, {
  287. 'obj': obj,
  288. 'obj_type': self.queryset.model._meta.verbose_name,
  289. 'form': form,
  290. 'return_url': self.get_return_url(request, obj),
  291. })
  292. def post(self, request, *args, **kwargs):
  293. logger = logging.getLogger('netbox.views.ObjectEditView')
  294. obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
  295. form = self.model_form(
  296. data=request.POST,
  297. files=request.FILES,
  298. instance=obj
  299. )
  300. restrict_form_fields(form, request.user)
  301. if form.is_valid():
  302. logger.debug("Form validation was successful")
  303. try:
  304. with transaction.atomic():
  305. object_created = form.instance.pk is None
  306. obj = form.save()
  307. # Check that the new object conforms with any assigned object-level permissions
  308. self.queryset.get(pk=obj.pk)
  309. msg = '{} {}'.format(
  310. 'Created' if object_created else 'Modified',
  311. self.queryset.model._meta.verbose_name
  312. )
  313. logger.info(f"{msg} {obj} (PK: {obj.pk})")
  314. if hasattr(obj, 'get_absolute_url'):
  315. msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
  316. else:
  317. msg = '{} {}'.format(msg, escape(obj))
  318. messages.success(request, mark_safe(msg))
  319. if '_addanother' in request.POST:
  320. # If the object has clone_fields, pre-populate a new instance of the form
  321. if hasattr(obj, 'clone_fields'):
  322. url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
  323. return redirect(url)
  324. return redirect(request.get_full_path())
  325. return_url = form.cleaned_data.get('return_url')
  326. if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
  327. return redirect(return_url)
  328. else:
  329. return redirect(self.get_return_url(request, obj))
  330. except ObjectDoesNotExist:
  331. msg = "Object save failed due to object-level permissions violation"
  332. logger.debug(msg)
  333. form.add_error(None, msg)
  334. else:
  335. logger.debug("Form validation failed")
  336. return render(request, self.template_name, {
  337. 'obj': obj,
  338. 'obj_type': self.queryset.model._meta.verbose_name,
  339. 'form': form,
  340. 'return_url': self.get_return_url(request, obj),
  341. })
  342. class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  343. """
  344. Delete a single object.
  345. queryset: The base queryset for the object being deleted
  346. template_name: The name of the template
  347. """
  348. queryset = None
  349. template_name = 'utilities/obj_delete.html'
  350. def get_required_permission(self):
  351. return get_permission_for_model(self.queryset.model, 'delete')
  352. def get_object(self, kwargs):
  353. # Look up object by slug if one has been provided. Otherwise, use PK.
  354. if 'slug' in kwargs:
  355. return get_object_or_404(self.queryset, slug=kwargs['slug'])
  356. else:
  357. return get_object_or_404(self.queryset, pk=kwargs['pk'])
  358. def get(self, request, **kwargs):
  359. obj = self.get_object(kwargs)
  360. form = ConfirmationForm(initial=request.GET)
  361. return render(request, self.template_name, {
  362. 'obj': obj,
  363. 'form': form,
  364. 'obj_type': self.queryset.model._meta.verbose_name,
  365. 'return_url': self.get_return_url(request, obj),
  366. })
  367. def post(self, request, **kwargs):
  368. logger = logging.getLogger('netbox.views.ObjectDeleteView')
  369. obj = self.get_object(kwargs)
  370. form = ConfirmationForm(request.POST)
  371. if form.is_valid():
  372. logger.debug("Form validation was successful")
  373. try:
  374. obj.delete()
  375. except ProtectedError as e:
  376. logger.info("Caught ProtectedError while attempting to delete object")
  377. handle_protectederror([obj], request, e)
  378. return redirect(obj.get_absolute_url())
  379. msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
  380. logger.info(msg)
  381. messages.success(request, msg)
  382. return_url = form.cleaned_data.get('return_url')
  383. if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
  384. return redirect(return_url)
  385. else:
  386. return redirect(self.get_return_url(request, obj))
  387. else:
  388. logger.debug("Form validation failed")
  389. return render(request, self.template_name, {
  390. 'obj': obj,
  391. 'form': form,
  392. 'obj_type': self.queryset.model._meta.verbose_name,
  393. 'return_url': self.get_return_url(request, obj),
  394. })
  395. class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  396. """
  397. Create new objects in bulk.
  398. queryset: Base queryset for the objects being created
  399. form: Form class which provides the `pattern` field
  400. model_form: The ModelForm used to create individual objects
  401. pattern_target: Name of the field to be evaluated as a pattern (if any)
  402. template_name: The name of the template
  403. """
  404. queryset = None
  405. form = None
  406. model_form = None
  407. pattern_target = ''
  408. template_name = None
  409. def get_required_permission(self):
  410. return get_permission_for_model(self.queryset.model, 'add')
  411. def get(self, request):
  412. # Set initial values for visible form fields from query args
  413. initial = {}
  414. for field in getattr(self.model_form._meta, 'fields', []):
  415. if request.GET.get(field):
  416. initial[field] = request.GET[field]
  417. form = self.form()
  418. model_form = self.model_form(initial=initial)
  419. return render(request, self.template_name, {
  420. 'obj_type': self.model_form._meta.model._meta.verbose_name,
  421. 'form': form,
  422. 'model_form': model_form,
  423. 'return_url': self.get_return_url(request),
  424. })
  425. def post(self, request):
  426. logger = logging.getLogger('netbox.views.BulkCreateView')
  427. model = self.queryset.model
  428. form = self.form(request.POST)
  429. model_form = self.model_form(request.POST)
  430. if form.is_valid():
  431. logger.debug("Form validation was successful")
  432. pattern = form.cleaned_data['pattern']
  433. new_objs = []
  434. try:
  435. with transaction.atomic():
  436. # Create objects from the expanded. Abort the transaction on the first validation error.
  437. for value in pattern:
  438. # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
  439. # copy of the POST QueryDict so that we can update the target field value.
  440. model_form = self.model_form(request.POST.copy())
  441. model_form.data[self.pattern_target] = value
  442. # Validate each new object independently.
  443. if model_form.is_valid():
  444. obj = model_form.save()
  445. logger.debug(f"Created {obj} (PK: {obj.pk})")
  446. new_objs.append(obj)
  447. else:
  448. # Copy any errors on the pattern target field to the pattern form.
  449. errors = model_form.errors.as_data()
  450. if errors.get(self.pattern_target):
  451. form.add_error('pattern', errors[self.pattern_target])
  452. # Raise an IntegrityError to break the for loop and abort the transaction.
  453. raise IntegrityError()
  454. # Enforce object-level permissions
  455. if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
  456. raise ObjectDoesNotExist
  457. # If we make it to this point, validation has succeeded on all new objects.
  458. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
  459. logger.info(msg)
  460. messages.success(request, msg)
  461. if '_addanother' in request.POST:
  462. return redirect(request.path)
  463. return redirect(self.get_return_url(request))
  464. except IntegrityError:
  465. pass
  466. except ObjectDoesNotExist:
  467. msg = "Object creation failed due to object-level permissions violation"
  468. logger.debug(msg)
  469. form.add_error(None, msg)
  470. else:
  471. logger.debug("Form validation failed")
  472. return render(request, self.template_name, {
  473. 'form': form,
  474. 'model_form': model_form,
  475. 'obj_type': model._meta.verbose_name,
  476. 'return_url': self.get_return_url(request),
  477. })
  478. class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  479. """
  480. Import a single object (YAML or JSON format).
  481. queryset: Base queryset for the objects being created
  482. model_form: The ModelForm used to create individual objects
  483. related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects
  484. template_name: The name of the template
  485. """
  486. queryset = None
  487. model_form = None
  488. related_object_forms = dict()
  489. template_name = 'utilities/obj_import.html'
  490. def get_required_permission(self):
  491. return get_permission_for_model(self.queryset.model, 'add')
  492. def get(self, request):
  493. form = ImportForm()
  494. return render(request, self.template_name, {
  495. 'form': form,
  496. 'obj_type': self.queryset.model._meta.verbose_name,
  497. 'return_url': self.get_return_url(request),
  498. })
  499. def post(self, request):
  500. logger = logging.getLogger('netbox.views.ObjectImportView')
  501. form = ImportForm(request.POST)
  502. if form.is_valid():
  503. logger.debug("Import form validation was successful")
  504. # Initialize model form
  505. data = form.cleaned_data['data']
  506. model_form = self.model_form(data)
  507. restrict_form_fields(model_form, request.user)
  508. # Assign default values for any fields which were not specified. We have to do this manually because passing
  509. # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
  510. # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
  511. # applicable field defaults as needed prior to form validation.
  512. for field_name, field in model_form.fields.items():
  513. if field_name not in data and hasattr(field, 'initial'):
  514. model_form.data[field_name] = field.initial
  515. if model_form.is_valid():
  516. try:
  517. with transaction.atomic():
  518. # Save the primary object
  519. obj = model_form.save()
  520. # Enforce object-level permissions
  521. self.queryset.get(pk=obj.pk)
  522. logger.debug(f"Created {obj} (PK: {obj.pk})")
  523. # Iterate through the related object forms (if any), validating and saving each instance.
  524. for field_name, related_object_form in self.related_object_forms.items():
  525. logger.debug("Processing form for related objects: {related_object_form}")
  526. related_obj_pks = []
  527. for i, rel_obj_data in enumerate(data.get(field_name, list())):
  528. f = related_object_form(obj, rel_obj_data)
  529. for subfield_name, field in f.fields.items():
  530. if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
  531. f.data[subfield_name] = field.initial
  532. if f.is_valid():
  533. related_obj = f.save()
  534. related_obj_pks.append(related_obj.pk)
  535. else:
  536. # Replicate errors on the related object form to the primary form for display
  537. for subfield_name, errors in f.errors.items():
  538. for err in errors:
  539. err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
  540. model_form.add_error(None, err_msg)
  541. raise AbortTransaction()
  542. # Enforce object-level permissions on related objects
  543. model = related_object_form.Meta.model
  544. if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
  545. raise ObjectDoesNotExist
  546. except AbortTransaction:
  547. pass
  548. except ObjectDoesNotExist:
  549. msg = "Object creation failed due to object-level permissions violation"
  550. logger.debug(msg)
  551. form.add_error(None, msg)
  552. if not model_form.errors:
  553. logger.info(f"Import object {obj} (PK: {obj.pk})")
  554. messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
  555. obj.get_absolute_url(), obj
  556. )))
  557. if '_addanother' in request.POST:
  558. return redirect(request.get_full_path())
  559. return_url = form.cleaned_data.get('return_url')
  560. if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
  561. return redirect(return_url)
  562. else:
  563. return redirect(self.get_return_url(request, obj))
  564. else:
  565. logger.debug("Model form validation failed")
  566. # Replicate model form errors for display
  567. for field, errors in model_form.errors.items():
  568. for err in errors:
  569. if field == '__all__':
  570. form.add_error(None, err)
  571. else:
  572. form.add_error(None, "{}: {}".format(field, err))
  573. else:
  574. logger.debug("Import form validation failed")
  575. return render(request, self.template_name, {
  576. 'form': form,
  577. 'obj_type': self.queryset.model._meta.verbose_name,
  578. 'return_url': self.get_return_url(request),
  579. })
  580. class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  581. """
  582. Import objects in bulk (CSV format).
  583. queryset: Base queryset for the model
  584. model_form: The form used to create each imported object
  585. table: The django-tables2 Table used to render the list of imported objects
  586. template_name: The name of the template
  587. widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
  588. """
  589. queryset = None
  590. model_form = None
  591. table = None
  592. template_name = 'utilities/obj_bulk_import.html'
  593. widget_attrs = {}
  594. def _import_form(self, *args, **kwargs):
  595. class ImportForm(BootstrapMixin, Form):
  596. csv = CSVDataField(
  597. from_form=self.model_form,
  598. widget=Textarea(attrs=self.widget_attrs)
  599. )
  600. return ImportForm(*args, **kwargs)
  601. def _save_obj(self, obj_form, request):
  602. """
  603. Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
  604. """
  605. return obj_form.save()
  606. def get_required_permission(self):
  607. return get_permission_for_model(self.queryset.model, 'add')
  608. def get(self, request):
  609. return render(request, self.template_name, {
  610. 'form': self._import_form(),
  611. 'fields': self.model_form().fields,
  612. 'obj_type': self.model_form._meta.model._meta.verbose_name,
  613. 'return_url': self.get_return_url(request),
  614. })
  615. def post(self, request):
  616. logger = logging.getLogger('netbox.views.BulkImportView')
  617. new_objs = []
  618. form = self._import_form(request.POST)
  619. if form.is_valid():
  620. logger.debug("Form validation was successful")
  621. try:
  622. # Iterate through CSV data and bind each row to a new model form instance.
  623. with transaction.atomic():
  624. headers, records = form.cleaned_data['csv']
  625. for row, data in enumerate(records, start=1):
  626. obj_form = self.model_form(data, headers=headers)
  627. restrict_form_fields(obj_form, request.user)
  628. if obj_form.is_valid():
  629. obj = self._save_obj(obj_form, request)
  630. new_objs.append(obj)
  631. else:
  632. for field, err in obj_form.errors.items():
  633. form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
  634. raise ValidationError("")
  635. # Enforce object-level permissions
  636. if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
  637. raise ObjectDoesNotExist
  638. # Compile a table containing the imported objects
  639. obj_table = self.table(new_objs)
  640. if new_objs:
  641. msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
  642. logger.info(msg)
  643. messages.success(request, msg)
  644. return render(request, "import_success.html", {
  645. 'table': obj_table,
  646. 'return_url': self.get_return_url(request),
  647. })
  648. except ValidationError:
  649. pass
  650. except ObjectDoesNotExist:
  651. msg = "Object import failed due to object-level permissions violation"
  652. logger.debug(msg)
  653. form.add_error(None, msg)
  654. else:
  655. logger.debug("Form validation failed")
  656. return render(request, self.template_name, {
  657. 'form': form,
  658. 'fields': self.model_form().fields,
  659. 'obj_type': self.model_form._meta.model._meta.verbose_name,
  660. 'return_url': self.get_return_url(request),
  661. })
  662. class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  663. """
  664. Edit objects in bulk.
  665. queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
  666. filter: FilterSet to apply when deleting by QuerySet
  667. table: The table used to display devices being edited
  668. form: The form class used to edit objects in bulk
  669. template_name: The name of the template
  670. """
  671. queryset = None
  672. filterset = None
  673. table = None
  674. form = None
  675. template_name = 'utilities/obj_bulk_edit.html'
  676. def get_required_permission(self):
  677. return get_permission_for_model(self.queryset.model, 'change')
  678. def get(self, request):
  679. return redirect(self.get_return_url(request))
  680. def post(self, request, **kwargs):
  681. logger = logging.getLogger('netbox.views.BulkEditView')
  682. model = self.queryset.model
  683. # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
  684. if request.POST.get('_all') and self.filterset is not None:
  685. pk_list = [
  686. obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
  687. ]
  688. else:
  689. pk_list = request.POST.getlist('pk')
  690. if '_apply' in request.POST:
  691. form = self.form(model, request.POST)
  692. restrict_form_fields(form, request.user)
  693. if form.is_valid():
  694. logger.debug("Form validation was successful")
  695. custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
  696. standard_fields = [
  697. field for field in form.fields if field not in custom_fields + ['pk']
  698. ]
  699. nullified_fields = request.POST.getlist('_nullify')
  700. try:
  701. with transaction.atomic():
  702. updated_objects = []
  703. for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
  704. # Update standard fields. If a field is listed in _nullify, delete its value.
  705. for name in standard_fields:
  706. try:
  707. model_field = model._meta.get_field(name)
  708. except FieldDoesNotExist:
  709. # This form field is used to modify a field rather than set its value directly
  710. model_field = None
  711. # Handle nullification
  712. if name in form.nullable_fields and name in nullified_fields:
  713. if isinstance(model_field, ManyToManyField):
  714. getattr(obj, name).set([])
  715. else:
  716. setattr(obj, name, None if model_field.null else '')
  717. # ManyToManyFields
  718. elif isinstance(model_field, ManyToManyField):
  719. if form.cleaned_data[name]:
  720. getattr(obj, name).set(form.cleaned_data[name])
  721. # Normal fields
  722. elif form.cleaned_data[name] not in (None, ''):
  723. setattr(obj, name, form.cleaned_data[name])
  724. # Update custom fields
  725. for name in custom_fields:
  726. if name in form.nullable_fields and name in nullified_fields:
  727. obj.custom_field_data.pop(name, None)
  728. else:
  729. obj.custom_field_data[name] = form.cleaned_data[name]
  730. obj.full_clean()
  731. obj.save()
  732. updated_objects.append(obj)
  733. logger.debug(f"Saved {obj} (PK: {obj.pk})")
  734. # Add/remove tags
  735. if form.cleaned_data.get('add_tags', None):
  736. obj.tags.add(*form.cleaned_data['add_tags'])
  737. if form.cleaned_data.get('remove_tags', None):
  738. obj.tags.remove(*form.cleaned_data['remove_tags'])
  739. # Enforce object-level permissions
  740. if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
  741. raise ObjectDoesNotExist
  742. if updated_objects:
  743. msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
  744. logger.info(msg)
  745. messages.success(self.request, msg)
  746. return redirect(self.get_return_url(request))
  747. except ValidationError as e:
  748. messages.error(self.request, "{} failed validation: {}".format(obj, e))
  749. except ObjectDoesNotExist:
  750. msg = "Object update failed due to object-level permissions violation"
  751. logger.debug(msg)
  752. form.add_error(None, msg)
  753. else:
  754. logger.debug("Form validation failed")
  755. else:
  756. # Include the PK list as initial data for the form
  757. initial_data = {'pk': pk_list}
  758. # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
  759. # filter values will conflict with the bulk edit form fields.
  760. # TODO: Find a better way to accomplish this
  761. if 'device' in request.GET:
  762. initial_data['device'] = request.GET.get('device')
  763. elif 'device_type' in request.GET:
  764. initial_data['device_type'] = request.GET.get('device_type')
  765. form = self.form(model, initial=initial_data)
  766. restrict_form_fields(form, request.user)
  767. # Retrieve objects being edited
  768. table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
  769. if not table.rows:
  770. messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
  771. return redirect(self.get_return_url(request))
  772. return render(request, self.template_name, {
  773. 'form': form,
  774. 'table': table,
  775. 'obj_type_plural': model._meta.verbose_name_plural,
  776. 'return_url': self.get_return_url(request),
  777. })
  778. class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  779. """
  780. An extendable view for renaming objects in bulk.
  781. """
  782. queryset = None
  783. template_name = 'utilities/obj_bulk_rename.html'
  784. def __init__(self, *args, **kwargs):
  785. super().__init__(*args, **kwargs)
  786. # Create a new Form class from BulkRenameForm
  787. class _Form(BulkRenameForm):
  788. pk = ModelMultipleChoiceField(
  789. queryset=self.queryset,
  790. widget=MultipleHiddenInput()
  791. )
  792. self.form = _Form
  793. def get_required_permission(self):
  794. return get_permission_for_model(self.queryset.model, 'change')
  795. def post(self, request):
  796. logger = logging.getLogger('netbox.views.BulkRenameView')
  797. if '_preview' in request.POST or '_apply' in request.POST:
  798. form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
  799. selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
  800. if form.is_valid():
  801. try:
  802. with transaction.atomic():
  803. renamed_pks = []
  804. for obj in selected_objects:
  805. find = form.cleaned_data['find']
  806. replace = form.cleaned_data['replace']
  807. if form.cleaned_data['use_regex']:
  808. try:
  809. obj.new_name = re.sub(find, replace, obj.name)
  810. # Catch regex group reference errors
  811. except re.error:
  812. obj.new_name = obj.name
  813. else:
  814. obj.new_name = obj.name.replace(find, replace)
  815. renamed_pks.append(obj.pk)
  816. if '_apply' in request.POST:
  817. for obj in selected_objects:
  818. obj.name = obj.new_name
  819. obj.save()
  820. # Enforce constrained permissions
  821. if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
  822. raise ObjectDoesNotExist
  823. messages.success(request, "Renamed {} {}".format(
  824. len(selected_objects),
  825. self.queryset.model._meta.verbose_name_plural
  826. ))
  827. return redirect(self.get_return_url(request))
  828. except ObjectDoesNotExist:
  829. msg = "Object update failed due to object-level permissions violation"
  830. logger.debug(msg)
  831. form.add_error(None, msg)
  832. else:
  833. form = self.form(initial={'pk': request.POST.getlist('pk')})
  834. selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
  835. return render(request, self.template_name, {
  836. 'form': form,
  837. 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
  838. 'selected_objects': selected_objects,
  839. 'return_url': self.get_return_url(request),
  840. })
  841. class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  842. """
  843. Delete objects in bulk.
  844. queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
  845. filter: FilterSet to apply when deleting by QuerySet
  846. table: The table used to display devices being deleted
  847. form: The form class used to delete objects in bulk
  848. template_name: The name of the template
  849. """
  850. queryset = None
  851. filterset = None
  852. table = None
  853. form = None
  854. template_name = 'utilities/obj_bulk_delete.html'
  855. def get_required_permission(self):
  856. return get_permission_for_model(self.queryset.model, 'delete')
  857. def get(self, request):
  858. return redirect(self.get_return_url(request))
  859. def post(self, request, **kwargs):
  860. logger = logging.getLogger('netbox.views.BulkDeleteView')
  861. model = self.queryset.model
  862. # Are we deleting *all* objects in the queryset or just a selected subset?
  863. if request.POST.get('_all'):
  864. if self.filterset is not None:
  865. pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
  866. else:
  867. pk_list = model.objects.values_list('pk', flat=True)
  868. else:
  869. pk_list = [int(pk) for pk in request.POST.getlist('pk')]
  870. form_cls = self.get_form()
  871. if '_confirm' in request.POST:
  872. form = form_cls(request.POST)
  873. if form.is_valid():
  874. logger.debug("Form validation was successful")
  875. # Delete objects
  876. queryset = self.queryset.filter(pk__in=pk_list)
  877. try:
  878. deleted_count = queryset.delete()[1][model._meta.label]
  879. except ProtectedError as e:
  880. logger.info("Caught ProtectedError while attempting to delete objects")
  881. handle_protectederror(queryset, request, e)
  882. return redirect(self.get_return_url(request))
  883. msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
  884. logger.info(msg)
  885. messages.success(request, msg)
  886. return redirect(self.get_return_url(request))
  887. else:
  888. logger.debug("Form validation failed")
  889. else:
  890. form = form_cls(initial={
  891. 'pk': pk_list,
  892. 'return_url': self.get_return_url(request),
  893. })
  894. # Retrieve objects being deleted
  895. table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
  896. if not table.rows:
  897. messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
  898. return redirect(self.get_return_url(request))
  899. return render(request, self.template_name, {
  900. 'form': form,
  901. 'obj_type_plural': model._meta.verbose_name_plural,
  902. 'table': table,
  903. 'return_url': self.get_return_url(request),
  904. })
  905. def get_form(self):
  906. """
  907. Provide a standard bulk delete form if none has been specified for the view
  908. """
  909. class BulkDeleteForm(ConfirmationForm):
  910. pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
  911. if self.form:
  912. return self.form
  913. return BulkDeleteForm
  914. #
  915. # Device/VirtualMachine components
  916. #
  917. # TODO: Replace with BulkCreateView
  918. class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  919. """
  920. Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
  921. """
  922. queryset = None
  923. form = None
  924. model_form = None
  925. template_name = None
  926. def get_required_permission(self):
  927. return get_permission_for_model(self.queryset.model, 'add')
  928. def get(self, request):
  929. form = self.form(initial=request.GET)
  930. return render(request, self.template_name, {
  931. 'component_type': self.queryset.model._meta.verbose_name,
  932. 'form': form,
  933. 'return_url': self.get_return_url(request),
  934. })
  935. def post(self, request):
  936. logger = logging.getLogger('netbox.views.ComponentCreateView')
  937. form = self.form(request.POST, initial=request.GET)
  938. if form.is_valid():
  939. new_components = []
  940. data = deepcopy(request.POST)
  941. names = form.cleaned_data['name_pattern']
  942. labels = form.cleaned_data.get('label_pattern')
  943. for i, name in enumerate(names):
  944. label = labels[i] if labels else None
  945. # Initialize the individual component form
  946. data['name'] = name
  947. data['label'] = label
  948. if hasattr(form, 'get_iterative_data'):
  949. data.update(form.get_iterative_data(i))
  950. component_form = self.model_form(data)
  951. if component_form.is_valid():
  952. new_components.append(component_form)
  953. else:
  954. for field, errors in component_form.errors.as_data().items():
  955. # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form
  956. if field == 'name':
  957. field = 'name_pattern'
  958. elif field == 'label':
  959. field = 'label_pattern'
  960. for e in errors:
  961. form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
  962. if not form.errors:
  963. try:
  964. with transaction.atomic():
  965. # Create the new components
  966. new_objs = []
  967. for component_form in new_components:
  968. obj = component_form.save()
  969. new_objs.append(obj)
  970. # Enforce object-level permissions
  971. if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
  972. raise ObjectDoesNotExist
  973. messages.success(request, "Added {} {}".format(
  974. len(new_components), self.queryset.model._meta.verbose_name_plural
  975. ))
  976. if '_addanother' in request.POST:
  977. return redirect(request.get_full_path())
  978. else:
  979. return redirect(self.get_return_url(request))
  980. except ObjectDoesNotExist:
  981. msg = "Component creation failed due to object-level permissions violation"
  982. logger.debug(msg)
  983. form.add_error(None, msg)
  984. return render(request, self.template_name, {
  985. 'component_type': self.queryset.model._meta.verbose_name,
  986. 'form': form,
  987. 'return_url': self.get_return_url(request),
  988. })
  989. class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
  990. """
  991. Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
  992. """
  993. parent_model = None
  994. parent_field = None
  995. form = None
  996. queryset = None
  997. model_form = None
  998. filterset = None
  999. table = None
  1000. template_name = 'utilities/obj_bulk_add_component.html'
  1001. def get_required_permission(self):
  1002. return f'dcim.add_{self.queryset.model._meta.model_name}'
  1003. def post(self, request):
  1004. logger = logging.getLogger('netbox.views.BulkComponentCreateView')
  1005. parent_model_name = self.parent_model._meta.verbose_name_plural
  1006. model_name = self.queryset.model._meta.verbose_name_plural
  1007. # Are we editing *all* objects in the queryset or just a selected subset?
  1008. if request.POST.get('_all') and self.filterset is not None:
  1009. pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs]
  1010. else:
  1011. pk_list = [int(pk) for pk in request.POST.getlist('pk')]
  1012. selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
  1013. if not selected_objects:
  1014. messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
  1015. return redirect(self.get_return_url(request))
  1016. table = self.table(selected_objects)
  1017. if '_create' in request.POST:
  1018. form = self.form(request.POST)
  1019. if form.is_valid():
  1020. logger.debug("Form validation was successful")
  1021. new_components = []
  1022. data = deepcopy(form.cleaned_data)
  1023. try:
  1024. with transaction.atomic():
  1025. for obj in data['pk']:
  1026. names = data['name_pattern']
  1027. labels = data['label_pattern'] if 'label_pattern' in data else None
  1028. for i, name in enumerate(names):
  1029. label = labels[i] if labels else None
  1030. component_data = {
  1031. self.parent_field: obj.pk,
  1032. 'name': name,
  1033. 'label': label
  1034. }
  1035. component_data.update(data)
  1036. component_form = self.model_form(component_data)
  1037. if component_form.is_valid():
  1038. instance = component_form.save()
  1039. logger.debug(f"Created {instance} on {instance.parent}")
  1040. new_components.append(instance)
  1041. else:
  1042. for field, errors in component_form.errors.as_data().items():
  1043. for e in errors:
  1044. form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
  1045. # Enforce object-level permissions
  1046. if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
  1047. raise ObjectDoesNotExist
  1048. except IntegrityError:
  1049. pass
  1050. except ObjectDoesNotExist:
  1051. msg = "Component creation failed due to object-level permissions violation"
  1052. logger.debug(msg)
  1053. form.add_error(None, msg)
  1054. if not form.errors:
  1055. msg = "Added {} {} to {} {}.".format(
  1056. len(new_components),
  1057. model_name,
  1058. len(form.cleaned_data['pk']),
  1059. parent_model_name
  1060. )
  1061. logger.info(msg)
  1062. messages.success(request, msg)
  1063. return redirect(self.get_return_url(request))
  1064. else:
  1065. logger.debug("Form validation failed")
  1066. else:
  1067. form = self.form(initial={'pk': pk_list})
  1068. return render(request, self.template_name, {
  1069. 'form': form,
  1070. 'parent_model_name': parent_model_name,
  1071. 'model_name': model_name,
  1072. 'table': table,
  1073. 'return_url': self.get_return_url(request),
  1074. })
  1075. @requires_csrf_token
  1076. def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
  1077. """
  1078. Custom 500 handler to provide additional context when rendering 500.html.
  1079. """
  1080. try:
  1081. template = loader.get_template(template_name)
  1082. except TemplateDoesNotExist:
  1083. return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
  1084. type_, error, traceback = sys.exc_info()
  1085. return HttpResponseServerError(template.render({
  1086. 'python_version': platform.python_version(),
  1087. 'netbox_version': settings.VERSION,
  1088. 'exception': str(type_),
  1089. 'error': error,
  1090. }))