views.py 46 KB

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