Coverage for utilities/views.py: 46%

162 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from collections.abc import Iterable 

2from dataclasses import dataclass 

3 

4from django.conf import settings 

5from django.contrib.auth.mixins import AccessMixin 

6from django.core.exceptions import ImproperlyConfigured 

7from django.db.models import QuerySet 

8from django.http import HttpResponseForbidden 

9from django.template import TemplateDoesNotExist 

10from django.template.loader import get_template 

11from django.urls import reverse 

12from django.urls.exceptions import NoReverseMatch 

13from django.utils.translation import gettext_lazy as _ 

14from rest_framework.exceptions import AuthenticationFailed 

15 

16from netbox.api.authentication import TokenAuthentication 

17from netbox.plugins import PluginConfig 

18from netbox.registry import registry 

19from utilities.relations import get_related_models 

20from utilities.request import safe_for_redirect 

21from utilities.string import title 

22 

23from .permissions import resolve_permission 

24 

25__all__ = ( 

26 'ConditionalLoginRequiredMixin', 

27 'ContentTypePermissionRequiredMixin', 

28 'GetRelatedModelsMixin', 

29 'GetReturnURLMixin', 

30 'ObjectPermissionRequiredMixin', 

31 'TokenConditionalLoginRequiredMixin', 

32 'ViewTab', 

33 'get_action_url', 

34 'get_default_template', 

35 'get_view', 

36 'get_viewname', 

37 'register_model_view', 

38) 

39 

40 

41# 

42# View Mixins 

43# 

44 

45class ConditionalLoginRequiredMixin(AccessMixin): 

46 """ 

47 Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True. 

48 """ 

49 def dispatch(self, request, *args, **kwargs): 

50 if settings.LOGIN_REQUIRED and not request.user.is_authenticated: 50 ↛ 52line 50 didn't jump to line 52 because the condition on line 50 was always true

51 return self.handle_no_permission() 

52 return super().dispatch(request, *args, **kwargs) 

53 

54 

55class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin): 

56 def dispatch(self, request, *args, **kwargs): 

57 # Attempt to authenticate the user using a DRF token, if provided 

58 if settings.LOGIN_REQUIRED and not request.user.is_authenticated: 

59 authenticator = TokenAuthentication() 

60 try: 

61 if (auth_info := authenticator.authenticate(request)) is not None: 

62 request.user = auth_info[0] # User object 

63 request.auth = auth_info[1] 

64 except AuthenticationFailed: 

65 return HttpResponseForbidden("Invalid token") 

66 

67 return super().dispatch(request, *args, **kwargs) 

68 

69 

70class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin): 

71 """ 

72 Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments. 

73 This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions, 

74 and fits within NetBox's custom permission enforcement system. 

75 

76 additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those 

77 derived from the object type 

78 """ 

79 additional_permissions = list() 

80 

81 def get_required_permission(self): 

82 """ 

83 Return the specific permission necessary to perform the requested action on an object. 

84 """ 

85 raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format( 

86 class_name=self.__class__.__name__ 

87 )) 

88 

89 def has_permission(self): 

90 user = self.request.user 

91 permission_required = self.get_required_permission() 

92 

93 # Check that the user has been granted the required permission(s). 

94 if user.has_perms((permission_required, *self.additional_permissions)): 

95 return True 

96 

97 return False 

98 

99 def dispatch(self, request, *args, **kwargs): 

100 if not self.has_permission(): 

101 return self.handle_no_permission() 

102 

103 return super().dispatch(request, *args, **kwargs) 

104 

105 

106class ObjectPermissionRequiredMixin(ConditionalLoginRequiredMixin): 

107 """ 

108 Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level 

109 permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered 

110 to return only those objects on which the user is permitted to perform the specified action. 

111 

112 additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those 

113 derived from the object type 

114 """ 

115 additional_permissions = list() 

116 

117 def get_required_permission(self): 

118 """ 

119 Return the specific permission necessary to perform the requested action on an object. 

120 """ 

121 raise NotImplementedError(_("{class_name} must implement get_required_permission()").format( 

122 class_name=self.__class__.__name__ 

123 )) 

124 

125 def has_permission(self): 

126 user = self.request.user 

127 permission_required = self.get_required_permission() 

128 

129 # Check that the user has been granted the required permission(s). 

130 if user.has_perms((permission_required, *self.additional_permissions)): 

131 

132 # Update the view's QuerySet to filter only the permitted objects 

133 action = resolve_permission(permission_required)[1] 

134 self.queryset = self.queryset.restrict(user, action) 

135 

136 return True 

137 

138 return False 

139 

140 def dispatch(self, request, *args, **kwargs): 

141 

142 if not hasattr(self, 'queryset'): 

143 raise ImproperlyConfigured( 

144 _( 

145 '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views ' 

146 'which define a base queryset' 

147 ).format(class_name=self.__class__.__name__) 

148 ) 

149 

150 if not self.has_permission(): 

151 return self.handle_no_permission() 

152 

153 return super().dispatch(request, *args, **kwargs) 

154 

155 

156class GetReturnURLMixin: 

157 """ 

158 Provides logic for determining where a user should be redirected after processing a form. 

159 """ 

160 default_return_url = None 

161 

162 def get_return_url(self, request, obj=None): 

163 

164 # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's 

165 # considered safe. 

166 return_url = request.GET.get('return_url') or request.POST.get('return_url') 

167 if return_url and safe_for_redirect(return_url): 

168 return return_url 

169 

170 # Next, check if the object being modified (if any) has an absolute URL. 

171 if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): 

172 return obj.get_absolute_url() 

173 

174 # Fall back to the default URL (if specified) for the view. 

175 if self.default_return_url is not None: 

176 return reverse(self.default_return_url) 

177 

178 # Attempt to dynamically resolve the list view for the object 

179 if hasattr(self, 'queryset'): 

180 try: 

181 return get_action_url(self.queryset.model, action='list') 

182 except NoReverseMatch: 

183 pass 

184 

185 # If all else fails, return home. Ideally this should never happen. 

186 return reverse('home') 

187 

188 

189class GetRelatedModelsMixin: 

190 """ 

191 Provides logic for collecting all related models for the currently viewed model. 

192 """ 

193 @dataclass 

194 class RelatedObjectCount: 

195 queryset: QuerySet 

196 filter_param: str 

197 label: str = '' 

198 

199 @property 

200 def name(self): 

201 return self.label or title(_(self.queryset.model._meta.verbose_name_plural)) 

202 

203 def get_related_models(self, request, instance, omit=None, extra=None, include_hidden=False): 

204 """ 

205 Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical. 

206 

207 Args: 

208 request: Current request being processed. 

209 instance: The instance related models should be looked up for. A list of instances can be passed to match 

210 related objects in this list (e.g. to find sites of a region including child regions). 

211 omit: Remove relationships to these models from the result. Needs to be passed, if related models don't 

212 provide a `_list` view. 

213 extra: Add extra models to the list of automatically determined related models. Can be used to add indirect 

214 relationships. 

215 include_hidden: Also match relationships declared with `related_name='+'`. 

216 """ 

217 omit = omit or [] 

218 model = self.queryset.model 

219 related = filter( 

220 lambda m: m[0] is not model and m[0] not in omit, 

221 get_related_models(model, ordered=False, include_hidden=include_hidden) 

222 ) 

223 

224 related_models = [ 

225 self.RelatedObjectCount( 

226 model.objects.restrict(request.user, 'view').filter(**( 

227 {f'{field}__in': instance} 

228 if isinstance(instance, Iterable) 

229 else {field: instance} 

230 )), 

231 f'{field}_id' 

232 ) 

233 for model, field in related 

234 ] 

235 if extra is not None: 

236 related_models.extend([ 

237 self.RelatedObjectCount(*attrs) for attrs in extra 

238 ]) 

239 

240 return sorted( 

241 filter(lambda roc: roc.queryset.exists(), related_models), 

242 key=lambda roc: roc.name, 

243 ) 

244 

245 

246class ViewTab: 

247 """ 

248 ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for 

249 a particular object. 

250 

251 Args: 

252 label: Human-friendly text 

253 visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one 

254 argument: the object instance. If a callable is not specified, the tab's visibility will be determined by 

255 its badge (if any) and the value of `hide_if_empty`. 

256 badge: A static value or callable to display alongside the label (optional). If a callable is used, it must 

257 accept a single argument representing the object being viewed. 

258 weight: Numeric weight to influence ordering among other tabs (default: 1000) 

259 permission: The permission required to display the tab (optional). 

260 hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is 

261 evaluated only if the tab is permitted to be displayed according to the `visible` parameter.) 

262 """ 

263 def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False): 

264 self.label = label 

265 self.visible = visible 

266 self.badge = badge 

267 self.weight = weight 

268 self.permission = permission 

269 self.hide_if_empty = hide_if_empty 

270 

271 def render(self, instance): 

272 """ 

273 Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None. 

274 """ 

275 if self.visible is not None and not self.visible(instance): 

276 return None 

277 badge_value = self._get_badge_value(instance) 

278 if self.badge and self.hide_if_empty and not badge_value: 

279 return None 

280 return { 

281 'label': self.label, 

282 'badge': badge_value, 

283 'weight': self.weight, 

284 } 

285 

286 def _get_badge_value(self, instance): 

287 if not self.badge: 

288 return None 

289 if callable(self.badge): 

290 return self.badge(instance) 

291 return self.badge 

292 

293 

294# 

295# Utility functions 

296# 

297 

298def get_viewname(model, action=None, rest_api=False): 

299 """ 

300 Return the view name for the given model and action, if valid. 

301 

302 :param model: The model or instance to which the view applies 

303 :param action: A string indicating the desired action (if any); e.g. "add" or "list" 

304 :param rest_api: A boolean indicating whether this is a REST API view 

305 """ 

306 is_plugin = isinstance(model._meta.app_config, PluginConfig) 

307 app_label = model._meta.app_label 

308 model_name = model._meta.model_name 

309 

310 if rest_api: 

311 viewname = f'{app_label}-api:{model_name}' 

312 if is_plugin: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 viewname = f'plugins-api:{viewname}' 

314 if action: 314 ↛ 324line 314 didn't jump to line 324 because the condition on line 314 was always true

315 viewname = f'{viewname}-{action}' 

316 

317 else: 

318 viewname = f'{app_label}:{model_name}' 

319 if is_plugin: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 viewname = f'plugins:{viewname}' 

321 if action: 

322 viewname = f'{viewname}_{action}' 

323 

324 return viewname 

325 

326 

327def get_action_url(model, action=None, rest_api=False, kwargs=None): 

328 """ 

329 Return the URL for the given model and action, if valid; otherwise raise NoReverseMatch. 

330 Will defer to _get_action_url() on the model if it exists. 

331 

332 :param model: The model or instance to which the URL belongs 

333 :param action: A string indicating the desired action (if any); e.g. "add" or "list" 

334 :param rest_api: A boolean indicating whether this is a REST API action 

335 :param kwargs: A dictionary of keyword arguments for the view to include when resolving its URL path (optional) 

336 """ 

337 if hasattr(model, '_get_action_url'): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return model._get_action_url(action, rest_api, kwargs) 

339 

340 return reverse(get_viewname(model, action, rest_api), kwargs=kwargs) 

341 

342 

343def get_default_template(model): 

344 """ 

345 Return the base template for the given model. If the presumed "{app}/{model}.html" template 

346 does not exist, fall back to "generic/object.html". 

347 """ 

348 template_name = f'{model._meta.app_label}/{model._meta.model_name}.html' 

349 try: 

350 get_template(template_name) 

351 return template_name 

352 except TemplateDoesNotExist: 

353 return 'generic/object.html' 

354 

355 

356def register_model_view(model, name='', path=None, detail=True, kwargs=None): 

357 """ 

358 This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject 

359 additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: 

360 

361 @register_model_view(Site, 'myview', path='my-custom-view') 

362 class MyView(ObjectView): 

363 ... 

364 

365 This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be 

366 resolved using the view name `dcim:site_myview'. 

367 

368 Args: 

369 model: The Django model class with which this view will be associated. 

370 name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended 

371 to the name of the base view for the model using an underscore. If blank, the model name will be used. 

372 path: The URL path by which the view can be reached (optional). If not provided, `name` will be used. 

373 detail: True if the path applied to an individual object; False if it attaches to the base (list) path. 

374 kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional). 

375 """ 

376 def _wrapper(cls): 

377 app_label = model._meta.app_label 

378 model_name = model._meta.model_name 

379 

380 if model_name not in registry['views'][app_label]: 

381 registry['views'][app_label][model_name] = [] 

382 

383 registry['views'][app_label][model_name].append({ 

384 'name': name, 

385 'view': cls, 

386 'path': path if path is not None else name, 

387 'detail': detail, 

388 'kwargs': kwargs or {}, 

389 }) 

390 

391 return cls 

392 

393 return _wrapper 

394 

395 

396def get_view(model, name=''): 

397 """ 

398 Return the view class registered for a model under the given name, or None if no matching view is registered. 

399 

400 Args: 

401 model: A model class or instance whose registered view should be returned. 

402 name: The name under which the view was registered (see `register_model_view()`). Defaults to the 

403 model's base (detail) view. 

404 """ 

405 app_label = model._meta.app_label 

406 model_name = model._meta.model_name 

407 views = registry['views'].get(app_label, {}).get(model_name, []) 

408 return next((v['view'] for v in views if v['name'] == name), None)