object_views.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import logging
  2. from collections import defaultdict
  3. from copy import deepcopy
  4. from django.contrib import messages
  5. from django.db import router, transaction
  6. from django.db.models import ProtectedError, RestrictedError
  7. from django.db.models.deletion import Collector
  8. from django.http import HttpResponse
  9. from django.shortcuts import redirect, render
  10. from django.urls import reverse
  11. from django.utils.html import escape
  12. from django.utils.safestring import mark_safe
  13. from django.utils.translation import gettext as _
  14. from extras.signals import clear_events
  15. from utilities.error_handlers import handle_protectederror
  16. from utilities.exceptions import AbortRequest, PermissionsViolation
  17. from utilities.forms import ConfirmationForm, restrict_form_fields
  18. from utilities.htmx import is_htmx
  19. from utilities.permissions import get_permission_for_model
  20. from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
  21. from utilities.views import GetReturnURLMixin
  22. from .base import BaseObjectView
  23. from .mixins import ActionsMixin, TableMixin
  24. from .utils import get_prerequisite_model
  25. __all__ = (
  26. 'ComponentCreateView',
  27. 'ObjectChildrenView',
  28. 'ObjectDeleteView',
  29. 'ObjectEditView',
  30. 'ObjectView',
  31. )
  32. class ObjectView(BaseObjectView):
  33. """
  34. Retrieve a single object for display.
  35. Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
  36. Attributes:
  37. tab: A ViewTab instance for the view
  38. """
  39. tab = None
  40. def get_required_permission(self):
  41. return get_permission_for_model(self.queryset.model, 'view')
  42. def get_template_name(self):
  43. """
  44. Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset
  45. model's `app_label` and `model_name`.
  46. """
  47. if self.template_name is not None:
  48. return self.template_name
  49. model_opts = self.queryset.model._meta
  50. return f'{model_opts.app_label}/{model_opts.model_name}.html'
  51. #
  52. # Request handlers
  53. #
  54. def get(self, request, **kwargs):
  55. """
  56. GET request handler. `*args` and `**kwargs` are passed to identify the object being queried.
  57. Args:
  58. request: The current request
  59. """
  60. instance = self.get_object(**kwargs)
  61. return render(request, self.get_template_name(), {
  62. 'object': instance,
  63. 'tab': self.tab,
  64. **self.get_extra_context(request, instance),
  65. })
  66. class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
  67. """
  68. Display a table of child objects associated with the parent object. For example, NetBox uses this to display
  69. the set of child IP addresses within a parent prefix.
  70. Attributes:
  71. child_model: The model class which represents the child objects
  72. table: The django-tables2 Table class used to render the child objects list
  73. filterset: A django-filter FilterSet that is applied to the queryset
  74. actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
  75. action names must be prefixed with `bulk_`. (See ActionsMixin.)
  76. """
  77. child_model = None
  78. table = None
  79. filterset = None
  80. def get_children(self, request, parent):
  81. """
  82. Return a QuerySet of child objects.
  83. Args:
  84. request: The current request
  85. parent: The parent object
  86. """
  87. raise NotImplementedError(_('{class_name} must implement get_children()').format(
  88. class_name=self.__class__.__name__
  89. ))
  90. def prep_table_data(self, request, queryset, parent):
  91. """
  92. Provides a hook for subclassed views to modify data before initializing the table.
  93. Args:
  94. request: The current request
  95. queryset: The filtered queryset of child objects
  96. parent: The parent object
  97. """
  98. return queryset
  99. #
  100. # Request handlers
  101. #
  102. def get(self, request, *args, **kwargs):
  103. """
  104. GET handler for rendering child objects.
  105. """
  106. instance = self.get_object(**kwargs)
  107. child_objects = self.get_children(request, instance)
  108. if self.filterset:
  109. child_objects = self.filterset(request.GET, child_objects, request=request).qs
  110. # Determine the available actions
  111. actions = self.get_permitted_actions(request.user, model=self.child_model)
  112. has_bulk_actions = any([a.startswith('bulk_') for a in actions])
  113. table_data = self.prep_table_data(request, child_objects, instance)
  114. table = self.get_table(table_data, request, has_bulk_actions)
  115. # If this is an HTMX request, return only the rendered table HTML
  116. if is_htmx(request):
  117. return render(request, 'htmx/table.html', {
  118. 'object': instance,
  119. 'table': table,
  120. })
  121. return render(request, self.get_template_name(), {
  122. 'object': instance,
  123. 'child_model': self.child_model,
  124. 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
  125. 'table': table,
  126. 'table_config': f'{table.name}_config',
  127. 'actions': actions,
  128. 'tab': self.tab,
  129. 'return_url': request.get_full_path(),
  130. **self.get_extra_context(request, instance),
  131. })
  132. class ObjectEditView(GetReturnURLMixin, BaseObjectView):
  133. """
  134. Create or edit a single object.
  135. Attributes:
  136. form: The form used to create or edit the object
  137. """
  138. template_name = 'generic/object_edit.html'
  139. form = None
  140. def dispatch(self, request, *args, **kwargs):
  141. # Determine required permission based on whether we are editing an existing object
  142. self._permission_action = 'change' if kwargs else 'add'
  143. return super().dispatch(request, *args, **kwargs)
  144. def get_required_permission(self):
  145. # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
  146. # we are modifying an existing object or creating a new one.
  147. return get_permission_for_model(self.queryset.model, self._permission_action)
  148. def get_object(self, **kwargs):
  149. """
  150. Return an object for editing. If no keyword arguments have been specified, this will be a new instance.
  151. """
  152. if not kwargs:
  153. # We're creating a new object
  154. return self.queryset.model()
  155. return super().get_object(**kwargs)
  156. def alter_object(self, obj, request, url_args, url_kwargs):
  157. """
  158. Provides a hook for views to modify an object before it is processed. For example, a parent object can be
  159. defined given some parameter from the request URL.
  160. Args:
  161. obj: The object being edited
  162. request: The current request
  163. url_args: URL path args
  164. url_kwargs: URL path kwargs
  165. """
  166. return obj
  167. def get_extra_addanother_params(self, request):
  168. """
  169. Return a dictionary of extra parameters to use on the Add Another button.
  170. """
  171. return {}
  172. #
  173. # Request handlers
  174. #
  175. def get(self, request, *args, **kwargs):
  176. """
  177. GET request handler.
  178. Args:
  179. request: The current request
  180. """
  181. obj = self.get_object(**kwargs)
  182. obj = self.alter_object(obj, request, args, kwargs)
  183. model = self.queryset.model
  184. initial_data = normalize_querydict(request.GET)
  185. form = self.form(instance=obj, initial=initial_data)
  186. restrict_form_fields(form, request.user)
  187. # If this is an HTMX request, return only the rendered form HTML
  188. if is_htmx(request):
  189. return render(request, 'htmx/form.html', {
  190. 'form': form,
  191. })
  192. return render(request, self.template_name, {
  193. 'model': model,
  194. 'object': obj,
  195. 'form': form,
  196. 'return_url': self.get_return_url(request, obj),
  197. 'prerequisite_model': get_prerequisite_model(self.queryset),
  198. **self.get_extra_context(request, obj),
  199. })
  200. def post(self, request, *args, **kwargs):
  201. """
  202. POST request handler.
  203. Args:
  204. request: The current request
  205. """
  206. logger = logging.getLogger('netbox.views.ObjectEditView')
  207. obj = self.get_object(**kwargs)
  208. # Take a snapshot for change logging (if editing an existing object)
  209. if obj.pk and hasattr(obj, 'snapshot'):
  210. obj.snapshot()
  211. obj = self.alter_object(obj, request, args, kwargs)
  212. form = self.form(data=request.POST, files=request.FILES, instance=obj)
  213. restrict_form_fields(form, request.user)
  214. if form.is_valid():
  215. logger.debug("Form validation was successful")
  216. try:
  217. with transaction.atomic():
  218. object_created = form.instance.pk is None
  219. obj = form.save()
  220. # Check that the new object conforms with any assigned object-level permissions
  221. if not self.queryset.filter(pk=obj.pk).exists():
  222. raise PermissionsViolation()
  223. msg = '{} {}'.format(
  224. 'Created' if object_created else 'Modified',
  225. self.queryset.model._meta.verbose_name
  226. )
  227. logger.info(f"{msg} {obj} (PK: {obj.pk})")
  228. if hasattr(obj, 'get_absolute_url'):
  229. msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
  230. else:
  231. msg = f'{msg} {obj}'
  232. messages.success(request, msg)
  233. if '_addanother' in request.POST:
  234. redirect_url = request.path
  235. # If cloning is supported, pre-populate a new instance of the form
  236. params = prepare_cloned_fields(obj)
  237. params.update(self.get_extra_addanother_params(request))
  238. if params:
  239. if 'return_url' in request.GET:
  240. params['return_url'] = request.GET.get('return_url')
  241. redirect_url += f"?{params.urlencode()}"
  242. return redirect(redirect_url)
  243. return_url = self.get_return_url(request, obj)
  244. return redirect(return_url)
  245. except (AbortRequest, PermissionsViolation) as e:
  246. logger.debug(e.message)
  247. form.add_error(None, e.message)
  248. clear_events.send(sender=self)
  249. else:
  250. logger.debug("Form validation failed")
  251. return render(request, self.template_name, {
  252. 'object': obj,
  253. 'form': form,
  254. 'return_url': self.get_return_url(request, obj),
  255. **self.get_extra_context(request, obj),
  256. })
  257. class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
  258. """
  259. Delete a single object.
  260. """
  261. template_name = 'generic/object_delete.html'
  262. def get_required_permission(self):
  263. return get_permission_for_model(self.queryset.model, 'delete')
  264. def _get_dependent_objects(self, obj):
  265. """
  266. Returns a dictionary mapping of dependent objects (organized by model) which will be deleted as a result of
  267. deleting the requested object.
  268. Args:
  269. obj: The object to return dependent objects for
  270. """
  271. using = router.db_for_write(obj._meta.model)
  272. collector = Collector(using=using)
  273. collector.collect([obj])
  274. # Compile a mapping of models to instances
  275. dependent_objects = defaultdict(list)
  276. for model, instances in collector.instances_with_model():
  277. # Ignore relations to auto-created models (e.g. many-to-many mappings)
  278. if model._meta.auto_created:
  279. continue
  280. # Omit the root object
  281. if instances == obj:
  282. continue
  283. dependent_objects[model].append(instances)
  284. return dict(dependent_objects)
  285. def _handle_protected_objects(self, obj, protected_objects, request, exc):
  286. """
  287. Handle a ProtectedError or RestrictedError exception raised while attempt to resolve dependent objects.
  288. """
  289. handle_protectederror(protected_objects, request, exc)
  290. if is_htmx(request):
  291. return HttpResponse(headers={
  292. 'HX-Redirect': obj.get_absolute_url(),
  293. })
  294. else:
  295. return redirect(obj.get_absolute_url())
  296. #
  297. # Request handlers
  298. #
  299. def get(self, request, *args, **kwargs):
  300. """
  301. GET request handler.
  302. Args:
  303. request: The current request
  304. """
  305. obj = self.get_object(**kwargs)
  306. form = ConfirmationForm(initial=request.GET)
  307. try:
  308. dependent_objects = self._get_dependent_objects(obj)
  309. except ProtectedError as e:
  310. return self._handle_protected_objects(obj, e.protected_objects, request, e)
  311. except RestrictedError as e:
  312. return self._handle_protected_objects(obj, e.restricted_objects, request, e)
  313. # If this is an HTMX request, return only the rendered deletion form as modal content
  314. if is_htmx(request):
  315. viewname = get_viewname(self.queryset.model, action='delete')
  316. form_url = reverse(viewname, kwargs={'pk': obj.pk})
  317. return render(request, 'htmx/delete_form.html', {
  318. 'object': obj,
  319. 'object_type': self.queryset.model._meta.verbose_name,
  320. 'form': form,
  321. 'form_url': form_url,
  322. 'dependent_objects': dependent_objects,
  323. **self.get_extra_context(request, obj),
  324. })
  325. return render(request, self.template_name, {
  326. 'object': obj,
  327. 'form': form,
  328. 'return_url': self.get_return_url(request, obj),
  329. 'dependent_objects': dependent_objects,
  330. **self.get_extra_context(request, obj),
  331. })
  332. def post(self, request, *args, **kwargs):
  333. """
  334. POST request handler.
  335. Args:
  336. request: The current request
  337. """
  338. logger = logging.getLogger('netbox.views.ObjectDeleteView')
  339. obj = self.get_object(**kwargs)
  340. form = ConfirmationForm(request.POST)
  341. # Take a snapshot of change-logged models
  342. if hasattr(obj, 'snapshot'):
  343. obj.snapshot()
  344. if form.is_valid():
  345. logger.debug("Form validation was successful")
  346. try:
  347. obj.delete()
  348. except (ProtectedError, RestrictedError) as e:
  349. logger.info(f"Caught {type(e)} while attempting to delete objects")
  350. handle_protectederror([obj], request, e)
  351. return redirect(obj.get_absolute_url())
  352. except AbortRequest as e:
  353. logger.debug(e.message)
  354. messages.error(request, mark_safe(e.message))
  355. return redirect(obj.get_absolute_url())
  356. msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
  357. logger.info(msg)
  358. messages.success(request, msg)
  359. return_url = form.cleaned_data.get('return_url')
  360. if return_url and return_url.startswith('/'):
  361. return redirect(return_url)
  362. return redirect(self.get_return_url(request, obj))
  363. else:
  364. logger.debug("Form validation failed")
  365. return render(request, self.template_name, {
  366. 'object': obj,
  367. 'form': form,
  368. 'return_url': self.get_return_url(request, obj),
  369. **self.get_extra_context(request, obj),
  370. })
  371. #
  372. # Device/VirtualMachine components
  373. #
  374. class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
  375. """
  376. Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
  377. """
  378. template_name = 'generic/object_edit.html'
  379. form = None
  380. model_form = None
  381. def get_required_permission(self):
  382. return get_permission_for_model(self.queryset.model, 'add')
  383. def alter_object(self, instance, request):
  384. return instance
  385. def initialize_form(self, request):
  386. data = request.POST if request.method == 'POST' else None
  387. initial_data = normalize_querydict(request.GET)
  388. form = self.form(data=data, initial=initial_data)
  389. return form
  390. def get(self, request):
  391. form = self.initialize_form(request)
  392. instance = self.alter_object(self.queryset.model(), request)
  393. # If this is an HTMX request, return only the rendered form HTML
  394. if is_htmx(request):
  395. return render(request, 'htmx/form.html', {
  396. 'form': form,
  397. })
  398. return render(request, self.template_name, {
  399. 'object': instance,
  400. 'form': form,
  401. 'return_url': self.get_return_url(request),
  402. })
  403. def post(self, request):
  404. logger = logging.getLogger('netbox.views.ComponentCreateView')
  405. form = self.initialize_form(request)
  406. instance = self.alter_object(self.queryset.model(), request)
  407. # Note that the form instance is a replicated field base
  408. # This is needed to avoid running custom validators multiple times
  409. form.instance._replicated_base = hasattr(self.form, "replication_fields")
  410. if form.is_valid():
  411. new_components = []
  412. data = deepcopy(request.POST)
  413. pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
  414. for i in range(pattern_count):
  415. for field_name in self.form.replication_fields:
  416. if form.cleaned_data.get(field_name):
  417. data[field_name] = form.cleaned_data[field_name][i]
  418. if hasattr(form, 'get_iterative_data'):
  419. data.update(form.get_iterative_data(i))
  420. component_form = self.model_form(data)
  421. if component_form.is_valid():
  422. new_components.append(component_form)
  423. else:
  424. form.errors.update(component_form.errors)
  425. break
  426. if not form.errors and not component_form.errors:
  427. try:
  428. with transaction.atomic():
  429. # Create the new components
  430. new_objs = []
  431. for component_form in new_components:
  432. obj = component_form.save()
  433. new_objs.append(obj)
  434. # Enforce object-level permissions
  435. if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
  436. raise PermissionsViolation
  437. messages.success(request, "Added {} {}".format(
  438. len(new_components), self.queryset.model._meta.verbose_name_plural
  439. ))
  440. # Redirect user on success
  441. if '_addanother' in request.POST:
  442. return redirect(request.get_full_path())
  443. else:
  444. return redirect(self.get_return_url(request))
  445. except (AbortRequest, PermissionsViolation) as e:
  446. logger.debug(e.message)
  447. form.add_error(None, e.message)
  448. clear_events.send(sender=self)
  449. return render(request, self.template_name, {
  450. 'object': instance,
  451. 'form': form,
  452. 'return_url': self.get_return_url(request),
  453. })