views.py 23 KB

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