views.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. from collections import OrderedDict
  2. from django_tables2 import RequestConfig
  3. from django.conf import settings
  4. from django.contrib import messages
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.db import transaction, IntegrityError
  7. from django.db.models import ProtectedError
  8. from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
  9. from django.http import HttpResponse
  10. from django.shortcuts import get_object_or_404, redirect, render
  11. from django.template import TemplateSyntaxError
  12. from django.urls import reverse
  13. from django.utils.html import escape
  14. from django.utils.http import is_safe_url
  15. from django.utils.safestring import mark_safe
  16. from django.views.generic import View
  17. from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
  18. from .error_handlers import handle_protectederror
  19. from .forms import ConfirmationForm
  20. from .paginator import EnhancedPaginator
  21. class CustomFieldQueryset:
  22. """
  23. Annotate custom fields on objects within a QuerySet.
  24. """
  25. def __init__(self, queryset, custom_fields):
  26. self.queryset = queryset
  27. self.custom_fields = custom_fields
  28. def __iter__(self):
  29. for obj in self.queryset:
  30. values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
  31. obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
  32. yield obj
  33. class GetReturnURLMixin(object):
  34. """
  35. Provides logic for determining where a user should be redirected after processing a form.
  36. """
  37. default_return_url = None
  38. def get_return_url(self, request, obj):
  39. query_param = request.GET.get('return_url')
  40. if query_param and is_safe_url(url=query_param, host=request.get_host()):
  41. return query_param
  42. elif obj.pk and hasattr(obj, 'get_absolute_url'):
  43. return obj.get_absolute_url()
  44. elif self.default_return_url is not None:
  45. return reverse(self.default_return_url)
  46. return reverse('home')
  47. class ObjectListView(View):
  48. """
  49. List a series of objects.
  50. queryset: The queryset of objects to display
  51. filter: A django-filter FilterSet that is applied to the queryset
  52. filter_form: The form used to render filter options
  53. table: The django-tables2 Table used to render the objects list
  54. template_name: The name of the template
  55. """
  56. queryset = None
  57. filter = None
  58. filter_form = None
  59. table = None
  60. template_name = None
  61. def get(self, request):
  62. model = self.queryset.model
  63. object_ct = ContentType.objects.get_for_model(model)
  64. if self.filter:
  65. self.queryset = self.filter(request.GET, self.queryset).qs
  66. # If this type of object has one or more custom fields, prefetch any relevant custom field values
  67. custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\
  68. .prefetch_related('choices')
  69. if custom_fields:
  70. self.queryset = self.queryset.prefetch_related('custom_field_values')
  71. # Check for export template rendering
  72. if request.GET.get('export'):
  73. et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export'))
  74. queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
  75. try:
  76. response = et.to_response(context_dict={'queryset': queryset},
  77. filename='netbox_{}'.format(model._meta.verbose_name_plural))
  78. return response
  79. except TemplateSyntaxError:
  80. messages.error(request, u"There was an error rendering the selected export template ({})."
  81. .format(et.name))
  82. # Fall back to built-in CSV export
  83. elif 'export' in request.GET and hasattr(model, 'to_csv'):
  84. output = '\n'.join([obj.to_csv() for obj in self.queryset])
  85. response = HttpResponse(
  86. output,
  87. content_type='text/csv'
  88. )
  89. response['Content-Disposition'] = 'attachment; filename="netbox_{}.csv"'\
  90. .format(self.queryset.model._meta.verbose_name_plural)
  91. return response
  92. # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
  93. self.queryset = self.alter_queryset(request)
  94. # Compile user model permissions for access from within the template
  95. perm_base_name = '{}.{{}}_{}'.format(model._meta.app_label, model._meta.model_name)
  96. permissions = {p: request.user.has_perm(perm_base_name.format(p)) for p in ['add', 'change', 'delete']}
  97. # Construct the table based on the user's permissions
  98. table = self.table(self.queryset)
  99. if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
  100. table.base_columns['pk'].visible = True
  101. # Apply the request context
  102. paginate = {
  103. 'klass': EnhancedPaginator,
  104. 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
  105. }
  106. RequestConfig(request, paginate).configure(table)
  107. context = {
  108. 'table': table,
  109. 'permissions': permissions,
  110. 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
  111. 'export_templates': ExportTemplate.objects.filter(content_type=object_ct),
  112. }
  113. context.update(self.extra_context())
  114. return render(request, self.template_name, context)
  115. def alter_queryset(self, request):
  116. # .all() is necessary to avoid caching queries
  117. return self.queryset.all()
  118. def extra_context(self):
  119. return {}
  120. class ObjectEditView(GetReturnURLMixin, View):
  121. """
  122. Create or edit a single object.
  123. model: The model of the object being edited
  124. form_class: The form used to create or edit the object
  125. template_name: The name of the template
  126. default_return_url: The name of the URL used to display a list of this object type
  127. """
  128. model = None
  129. form_class = None
  130. template_name = 'utilities/obj_edit.html'
  131. def get_object(self, kwargs):
  132. # Look up object by slug or PK. Return None if neither was provided.
  133. if 'slug' in kwargs:
  134. return get_object_or_404(self.model, slug=kwargs['slug'])
  135. elif 'pk' in kwargs:
  136. return get_object_or_404(self.model, pk=kwargs['pk'])
  137. return self.model()
  138. def alter_obj(self, obj, request, url_args, url_kwargs):
  139. # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
  140. # given some parameter from the request URL.
  141. return obj
  142. def get(self, request, *args, **kwargs):
  143. obj = self.get_object(kwargs)
  144. obj = self.alter_obj(obj, request, args, kwargs)
  145. # Parse initial data manually to avoid setting field values as lists
  146. initial_data = {k: request.GET[k] for k in request.GET}
  147. form = self.form_class(instance=obj, initial=initial_data)
  148. return render(request, self.template_name, {
  149. 'obj': obj,
  150. 'obj_type': self.model._meta.verbose_name,
  151. 'form': form,
  152. 'return_url': self.get_return_url(request, obj),
  153. })
  154. def post(self, request, *args, **kwargs):
  155. obj = self.get_object(kwargs)
  156. obj = self.alter_obj(obj, request, args, kwargs)
  157. form = self.form_class(request.POST, request.FILES, instance=obj)
  158. if form.is_valid():
  159. obj_created = not form.instance.pk
  160. obj = form.save()
  161. msg = u'Created ' if obj_created else u'Modified '
  162. msg += self.model._meta.verbose_name
  163. if hasattr(obj, 'get_absolute_url'):
  164. msg = u'{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
  165. else:
  166. msg = u'{} {}'.format(msg, escape(obj))
  167. messages.success(request, mark_safe(msg))
  168. if obj_created:
  169. UserAction.objects.log_create(request.user, obj, msg)
  170. else:
  171. UserAction.objects.log_edit(request.user, obj, msg)
  172. if '_addanother' in request.POST:
  173. return redirect(request.path)
  174. return_url = form.cleaned_data.get('return_url')
  175. if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
  176. return redirect(return_url)
  177. else:
  178. return redirect(self.get_return_url(request, obj))
  179. return render(request, self.template_name, {
  180. 'obj': obj,
  181. 'obj_type': self.model._meta.verbose_name,
  182. 'form': form,
  183. 'return_url': self.get_return_url(request, obj),
  184. })
  185. class ObjectDeleteView(GetReturnURLMixin, View):
  186. """
  187. Delete a single object.
  188. model: The model of the object being edited
  189. template_name: The name of the template
  190. default_return_url: Name of the URL to which the user is redirected after deleting the object
  191. """
  192. model = None
  193. template_name = 'utilities/obj_delete.html'
  194. def get_object(self, kwargs):
  195. # Look up object by slug if one has been provided. Otherwise, use PK.
  196. if 'slug' in kwargs:
  197. return get_object_or_404(self.model, slug=kwargs['slug'])
  198. else:
  199. return get_object_or_404(self.model, pk=kwargs['pk'])
  200. def get(self, request, **kwargs):
  201. obj = self.get_object(kwargs)
  202. form = ConfirmationForm(initial=request.GET)
  203. return render(request, self.template_name, {
  204. 'obj': obj,
  205. 'form': form,
  206. 'obj_type': self.model._meta.verbose_name,
  207. 'return_url': self.get_return_url(request, obj),
  208. })
  209. def post(self, request, **kwargs):
  210. obj = self.get_object(kwargs)
  211. form = ConfirmationForm(request.POST)
  212. if form.is_valid():
  213. try:
  214. obj.delete()
  215. except ProtectedError as e:
  216. handle_protectederror(obj, request, e)
  217. return redirect(obj.get_absolute_url())
  218. msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
  219. messages.success(request, msg)
  220. UserAction.objects.log_delete(request.user, obj, msg)
  221. return_url = form.cleaned_data.get('return_url')
  222. if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
  223. return redirect(return_url)
  224. else:
  225. return redirect(self.get_return_url(request, obj))
  226. return render(request, self.template_name, {
  227. 'obj': obj,
  228. 'form': form,
  229. 'obj_type': self.model._meta.verbose_name,
  230. 'return_url': self.get_return_url(request, obj),
  231. })
  232. class BulkAddView(View):
  233. """
  234. Create new objects in bulk.
  235. pattern_form: Form class which provides the `pattern` field
  236. model_form: The ModelForm used to create individual objects
  237. template_name: The name of the template
  238. default_return_url: Name of the URL to which the user is redirected after creating the objects
  239. """
  240. pattern_form = None
  241. model_form = None
  242. pattern_target = ''
  243. template_name = None
  244. default_return_url = 'home'
  245. def get(self, request):
  246. pattern_form = self.pattern_form()
  247. model_form = self.model_form()
  248. return render(request, self.template_name, {
  249. 'obj_type': self.model_form._meta.model._meta.verbose_name,
  250. 'pattern_form': pattern_form,
  251. 'model_form': model_form,
  252. 'return_url': reverse(self.default_return_url),
  253. })
  254. def post(self, request):
  255. model = self.model_form._meta.model
  256. pattern_form = self.pattern_form(request.POST)
  257. model_form = self.model_form(request.POST)
  258. if pattern_form.is_valid():
  259. pattern = pattern_form.cleaned_data['pattern']
  260. new_objs = []
  261. try:
  262. with transaction.atomic():
  263. # Create objects from the expanded. Abort the transaction on the first validation error.
  264. for value in pattern:
  265. # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
  266. # copy of the POST QueryDict so that we can update the target field value.
  267. model_form = self.model_form(request.POST.copy())
  268. model_form.data[self.pattern_target] = value
  269. # Validate each new object independently.
  270. if model_form.is_valid():
  271. obj = model_form.save()
  272. new_objs.append(obj)
  273. else:
  274. # Copy any errors on the pattern target field to the pattern form.
  275. errors = model_form.errors.as_data()
  276. if errors.get(self.pattern_target):
  277. pattern_form.add_error('pattern', errors[self.pattern_target])
  278. # Raise an IntegrityError to break the for loop and abort the transaction.
  279. raise IntegrityError()
  280. # If we make it to this point, validation has succeeded on all new objects.
  281. msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
  282. messages.success(request, msg)
  283. UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
  284. if '_addanother' in request.POST:
  285. return redirect(request.path)
  286. return redirect(self.default_return_url)
  287. except IntegrityError:
  288. pass
  289. return render(request, self.template_name, {
  290. 'pattern_form': pattern_form,
  291. 'model_form': model_form,
  292. 'obj_type': model._meta.verbose_name,
  293. 'return_url': reverse(self.default_return_url),
  294. })
  295. class BulkImportView(View):
  296. """
  297. Import objects in bulk (CSV format).
  298. form: Form class
  299. table: The django-tables2 Table used to render the list of imported objects
  300. template_name: The name of the template
  301. default_return_url: The name of the URL to use for the cancel button
  302. """
  303. form = None
  304. table = None
  305. template_name = None
  306. default_return_url = None
  307. def get(self, request):
  308. return render(request, self.template_name, {
  309. 'form': self.form(),
  310. 'return_url': self.default_return_url,
  311. })
  312. def post(self, request):
  313. form = self.form(request.POST)
  314. if form.is_valid():
  315. new_objs = []
  316. try:
  317. with transaction.atomic():
  318. for obj in form.cleaned_data['csv']:
  319. self.save_obj(obj)
  320. new_objs.append(obj)
  321. obj_table = self.table(new_objs)
  322. if new_objs:
  323. msg = u'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
  324. messages.success(request, msg)
  325. UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
  326. return render(request, "import_success.html", {
  327. 'table': obj_table,
  328. 'return_url': self.default_return_url,
  329. })
  330. except IntegrityError as e:
  331. form.add_error('csv', "Record {}: {}".format(len(new_objs) + 1, e.__cause__))
  332. return render(request, self.template_name, {
  333. 'form': form,
  334. 'return_url': self.default_return_url,
  335. })
  336. def save_obj(self, obj):
  337. obj.save()
  338. class BulkEditView(View):
  339. """
  340. Edit objects in bulk.
  341. cls: The model of the objects being edited
  342. parent_cls: The model of the parent object (if any)
  343. filter: FilterSet to apply when deleting by QuerySet
  344. form: The form class used to edit objects in bulk
  345. template_name: The name of the template
  346. default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by
  347. POSTing return_url)
  348. """
  349. cls = None
  350. parent_cls = None
  351. filter = None
  352. form = None
  353. template_name = None
  354. default_return_url = 'home'
  355. def get(self):
  356. return redirect(self.default_return_url)
  357. def post(self, request, **kwargs):
  358. # Attempt to derive parent object if a parent class has been given
  359. if self.parent_cls:
  360. parent_obj = get_object_or_404(self.parent_cls, **kwargs)
  361. else:
  362. parent_obj = None
  363. # Determine URL to redirect users upon modification of objects
  364. posted_return_url = request.POST.get('return_url')
  365. if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
  366. return_url = posted_return_url
  367. elif parent_obj:
  368. return_url = parent_obj.get_absolute_url()
  369. else:
  370. return_url = reverse(self.default_return_url)
  371. # Are we editing *all* objects in the queryset or just a selected subset?
  372. if request.POST.get('_all') and self.filter is not None:
  373. pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
  374. else:
  375. pk_list = [int(pk) for pk in request.POST.getlist('pk')]
  376. if '_apply' in request.POST:
  377. form = self.form(self.cls, request.POST)
  378. if form.is_valid():
  379. custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
  380. standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
  381. # Update standard fields. If a field is listed in _nullify, delete its value.
  382. nullified_fields = request.POST.getlist('_nullify')
  383. fields_to_update = {}
  384. for field in standard_fields:
  385. if field in form.nullable_fields and field in nullified_fields:
  386. if isinstance(form.fields[field], CharField):
  387. fields_to_update[field] = ''
  388. else:
  389. fields_to_update[field] = None
  390. elif form.cleaned_data[field] not in (None, ''):
  391. fields_to_update[field] = form.cleaned_data[field]
  392. updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
  393. # Update custom fields for objects
  394. if custom_fields:
  395. objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
  396. if objs_updated and not updated_count:
  397. updated_count = objs_updated
  398. if updated_count:
  399. msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
  400. messages.success(self.request, msg)
  401. UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
  402. return redirect(return_url)
  403. else:
  404. initial_data = request.POST.copy()
  405. initial_data['pk'] = pk_list
  406. form = self.form(self.cls, initial=initial_data)
  407. selected_objects = self.cls.objects.filter(pk__in=pk_list)
  408. if not selected_objects:
  409. messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural))
  410. return redirect(return_url)
  411. return render(request, self.template_name, {
  412. 'form': form,
  413. 'selected_objects': selected_objects,
  414. 'return_url': return_url,
  415. })
  416. def update_custom_fields(self, pk_list, form, fields, nullified_fields):
  417. obj_type = ContentType.objects.get_for_model(self.cls)
  418. objs_updated = False
  419. for name in fields:
  420. field = form.fields[name].model
  421. # Setting the field to null
  422. if name in form.nullable_fields and name in nullified_fields:
  423. # Delete all CustomFieldValues for instances of this field belonging to the selected objects.
  424. CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
  425. objs_updated = True
  426. # Updating the value of the field
  427. elif form.cleaned_data[name] not in [None, u'']:
  428. # Check for zero value (bulk editing)
  429. if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
  430. serialized_value = field.serialize_value(None)
  431. else:
  432. serialized_value = field.serialize_value(form.cleaned_data[name])
  433. # Gather any pre-existing CustomFieldValues for the objects being edited.
  434. existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
  435. # Determine which objects have an existing CFV to update and which need a new CFV created.
  436. update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
  437. create_list = list(set(pk_list) - set(update_list))
  438. # Creating/updating CFVs
  439. if serialized_value:
  440. existing_cfvs.update(serialized_value=serialized_value)
  441. CustomFieldValue.objects.bulk_create([
  442. CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
  443. for pk in create_list
  444. ])
  445. # Deleting CFVs
  446. else:
  447. existing_cfvs.delete()
  448. objs_updated = True
  449. return len(pk_list) if objs_updated else 0
  450. class BulkDeleteView(View):
  451. """
  452. Delete objects in bulk.
  453. cls: The model of the objects being deleted
  454. parent_cls: The model of the parent object (if any)
  455. filter: FilterSet to apply when deleting by QuerySet
  456. form: The form class used to delete objects in bulk
  457. template_name: The name of the template
  458. default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by
  459. POSTing return_url)
  460. """
  461. cls = None
  462. parent_cls = None
  463. filter = None
  464. form = None
  465. template_name = 'utilities/confirm_bulk_delete.html'
  466. default_return_url = 'home'
  467. def post(self, request, **kwargs):
  468. # Attempt to derive parent object if a parent class has been given
  469. if self.parent_cls:
  470. parent_obj = get_object_or_404(self.parent_cls, **kwargs)
  471. else:
  472. parent_obj = None
  473. # Determine URL to redirect users upon deletion of objects
  474. posted_return_url = request.POST.get('return_url')
  475. if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
  476. return_url = posted_return_url
  477. elif parent_obj:
  478. return_url = parent_obj.get_absolute_url()
  479. else:
  480. return_url = reverse(self.default_return_url)
  481. # Are we deleting *all* objects in the queryset or just a selected subset?
  482. if request.POST.get('_all') and self.filter is not None:
  483. pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
  484. else:
  485. pk_list = [int(pk) for pk in request.POST.getlist('pk')]
  486. form_cls = self.get_form()
  487. if '_confirm' in request.POST:
  488. form = form_cls(request.POST)
  489. if form.is_valid():
  490. # Delete objects
  491. queryset = self.cls.objects.filter(pk__in=pk_list)
  492. try:
  493. deleted_count = queryset.delete()[1][self.cls._meta.label]
  494. except ProtectedError as e:
  495. handle_protectederror(list(queryset), request, e)
  496. return redirect(return_url)
  497. msg = u'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
  498. messages.success(request, msg)
  499. UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
  500. return redirect(return_url)
  501. else:
  502. form = form_cls(initial={'pk': pk_list, 'return_url': return_url})
  503. selected_objects = self.cls.objects.filter(pk__in=pk_list)
  504. if not selected_objects:
  505. messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
  506. return redirect(return_url)
  507. return render(request, self.template_name, {
  508. 'form': form,
  509. 'parent_obj': parent_obj,
  510. 'obj_type_plural': self.cls._meta.verbose_name_plural,
  511. 'selected_objects': selected_objects,
  512. 'return_url': return_url,
  513. })
  514. def get_form(self):
  515. """
  516. Provide a standard bulk delete form if none has been specified for the view
  517. """
  518. class BulkDeleteForm(ConfirmationForm):
  519. pk = ModelMultipleChoiceField(queryset=self.cls.objects.all(), widget=MultipleHiddenInput)
  520. if self.form:
  521. return self.form
  522. return BulkDeleteForm