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
« 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
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
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
31__all__ = (
32 'HomeView',
33 'MediaView',
34 'SearchView',
35)
37logger = logging.getLogger(f'netbox.{__name__}')
39Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
42class HomeView(ConditionalLoginRequiredMixin, View):
43 template_name = 'home.html'
45 def get(self, request):
46 if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
47 return redirect('login')
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()
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
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 }
77 return render(request, self.template_name, {
78 'dashboard': dashboard,
79 'new_release': new_release,
80 })
83class SearchView(ConditionalLoginRequiredMixin, View):
85 def get(self, request):
86 results = []
87 highlight = None
89 # Initialize search form
90 form = SearchForm(request.GET) if 'q' in request.GET else SearchForm()
92 if form.is_valid():
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))
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 )
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']
117 table = SearchTable(results, highlight=highlight)
119 # Paginate the table results
120 RequestConfig(request, {
121 'paginator_class': EnhancedPaginator,
122 'per_page': get_paginate_count(request)
123 }).configure(table)
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 })
131 return render(request, 'search.html', {
132 'form': form,
133 'table': table,
134 })
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):
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('/')
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
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