Coverage for netbox/views/misc.py: 32%

90 statements  

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

1import logging 

2import posixpath 

3import re 

4from collections import namedtuple 

5 

6from django.conf import settings 

7from django.contrib import messages 

8from django.contrib.contenttypes.models import ContentType 

9from django.core.cache import cache 

10from django.db.models import Q 

11from django.http import Http404 

12from django.shortcuts import redirect, render 

13from django.utils.translation import gettext_lazy as _ 

14from django.views.generic import View 

15from django.views.static import serve 

16from django_tables2 import RequestConfig 

17from packaging import version 

18 

19from dcim.models import DeviceType 

20from extras.constants import DEFAULT_DASHBOARD 

21from extras.dashboard.utils import get_dashboard, get_default_dashboard 

22from extras.models import ImageAttachment 

23from netbox.forms import SearchForm 

24from netbox.search import LookupTypes 

25from netbox.search.backends import search_backend 

26from netbox.tables import SearchTable 

27from utilities.htmx import htmx_partial 

28from utilities.paginator import EnhancedPaginator, get_paginate_count 

29from utilities.views import ConditionalLoginRequiredMixin, TokenConditionalLoginRequiredMixin 

30 

31__all__ = ( 

32 'HomeView', 

33 'MediaView', 

34 'SearchView', 

35) 

36 

37logger = logging.getLogger(f'netbox.{__name__}') 

38 

39Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) 

40 

41 

42class HomeView(ConditionalLoginRequiredMixin, View): 

43 template_name = 'home.html' 

44 

45 def get(self, request): 

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

47 return redirect('login') 

48 

49 # Construct the user's custom dashboard layout 

50 try: 

51 dashboard = get_dashboard(request.user).get_layout() 

52 except Exception: 

53 messages.error(request, _( 

54 "There was an error loading the dashboard configuration. A default dashboard is in use." 

55 )) 

56 dashboard = get_default_dashboard(config=DEFAULT_DASHBOARD).get_layout() 

57 

58 # Check whether a new release is available. (Only for superusers.) 

59 new_release = None 

60 if request.user.is_superuser: 

61 # cache.get() can raise an exception if the cached value can't be unpickled after dependency upgrades 

62 try: 

63 latest_release = cache.get('latest_release') 

64 except Exception: 

65 logger.debug("Failed to read 'latest_release' from cache; deleting key", exc_info=True) 

66 cache.delete('latest_release') 

67 latest_release = None 

68 

69 if latest_release: 

70 release_version, release_url = latest_release 

71 if release_version > version.parse(settings.RELEASE.version): 

72 new_release = { 

73 'version': str(release_version), 

74 'url': release_url, 

75 } 

76 

77 return render(request, self.template_name, { 

78 'dashboard': dashboard, 

79 'new_release': new_release, 

80 }) 

81 

82 

83class SearchView(ConditionalLoginRequiredMixin, View): 

84 

85 def get(self, request): 

86 results = [] 

87 highlight = None 

88 

89 # Initialize search form 

90 form = SearchForm(request.GET) if 'q' in request.GET else SearchForm() 

91 

92 if form.is_valid(): 

93 

94 # Restrict results by object type 

95 object_types = [] 

96 for obj_type in form.cleaned_data['obj_types']: 

97 app_label, model_name = obj_type.split('.') 

98 object_types.append(ContentType.objects.get_by_natural_key(app_label, model_name)) 

99 

100 lookup = form.cleaned_data['lookup'] or LookupTypes.PARTIAL 

101 results = search_backend.search( 

102 form.cleaned_data['q'], 

103 user=request.user, 

104 object_types=object_types, 

105 lookup=lookup 

106 ) 

107 

108 # If performing a regex search, pass the highlight value as a compiled pattern 

109 if form.cleaned_data['lookup'] == LookupTypes.REGEX: 

110 try: 

111 highlight = re.compile(f"({form.cleaned_data['q']})", flags=re.IGNORECASE) 

112 except re.error: 

113 pass 

114 elif form.cleaned_data['lookup'] != LookupTypes.EXACT: 

115 highlight = form.cleaned_data['q'] 

116 

117 table = SearchTable(results, highlight=highlight) 

118 

119 # Paginate the table results 

120 RequestConfig(request, { 

121 'paginator_class': EnhancedPaginator, 

122 'per_page': get_paginate_count(request) 

123 }).configure(table) 

124 

125 # If this is an HTMX request, return only the rendered table HTML 

126 if htmx_partial(request): 

127 return render(request, 'htmx/table.html', { 

128 'table': table, 

129 }) 

130 

131 return render(request, 'search.html', { 

132 'form': form, 

133 'table': table, 

134 }) 

135 

136 

137class MediaView(TokenConditionalLoginRequiredMixin, View): 

138 """ 

139 Serve uploaded media files, enforcing authentication and view permission on the associated object. 

140 """ 

141 def get(self, request, path): 

142 

143 # Normalize the path to prevent traversal sequences (e.g. "foo/../image-attachments/...") 

144 # from bypassing the directory checks below. 

145 path = posixpath.normpath(path).lstrip('/') 

146 

147 # For known upload directories, resolve the path to an owning record and 

148 # enforce object-level view permission. restrict() returns .none() when the 

149 # user lacks permission, so a denial and a missing file are both 404s. 

150 # Paths outside these directories (e.g. plugin uploads) fall through 

151 # to the original behaviour. 

152 if path.startswith('image-attachments/'): 

153 if not ImageAttachment.objects.restrict(request.user, 'view').filter(image=path).exists(): 

154 raise Http404 

155 elif path.startswith('devicetype-images/'): 

156 if not DeviceType.objects.restrict(request.user, 'view').filter( 

157 Q(front_image=path) | Q(rear_image=path) 

158 ).exists(): 

159 raise Http404 

160 

161 response = serve(request, path, document_root=settings.MEDIA_ROOT) 

162 response['Content-Security-Policy'] = "sandbox; default-src 'none'" 

163 response['X-Content-Type-Options'] = "nosniff" 

164 return response