Coverage for netbox/views/generic/feature_views.py: 45%
108 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
1from django.contrib import messages
2from django.contrib.auth.mixins import LoginRequiredMixin
3from django.contrib.contenttypes.models import ContentType
4from django.db import router, transaction
5from django.db.models import Q
6from django.shortcuts import get_object_or_404, redirect, render
7from django.utils.translation import gettext_lazy as _
8from django.views.generic import View
10from core.models import Job, ObjectChange
11from core.tables import JobTable, ObjectChangeTable
12from extras.forms import JournalEntryForm
13from extras.models import ImageAttachment, JournalEntry
14from extras.tables import JournalEntryTable
15from tenancy.filtersets import ContactAssignmentFilterSet
16from tenancy.forms import ContactAssignmentFilterForm
17from tenancy.models import ContactAssignment
18from tenancy.tables import ContactAssignmentTable
19from utilities.permissions import get_permission_for_model
20from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab, get_default_template
22from .base import BaseMultiObjectView
23from .object_views import ObjectChildrenView
25__all__ = (
26 'BulkSyncDataView',
27 'ObjectChangeLogView',
28 'ObjectContactsView',
29 'ObjectImageAttachmentsView',
30 'ObjectJobsView',
31 'ObjectJournalView',
32 'ObjectSyncDataView',
33)
36class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
37 """
38 Present a history of changes made to a particular object. The model class must be passed as a keyword argument
39 when referencing this view in a URL path. For example:
41 path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
43 Attributes:
44 base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
45 """
46 base_template = None
47 tab = ViewTab(
48 label=_('Changelog'),
49 permission='core.view_objectchange',
50 weight=10000
51 )
53 def get(self, request, model, **kwargs):
55 # Handle QuerySet restriction of parent object if needed
56 if hasattr(model.objects, 'restrict'):
57 obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
58 else:
59 obj = get_object_or_404(model, **kwargs)
61 # Gather all changes for this object (and its related objects)
62 content_type = ContentType.objects.get_for_model(model)
63 objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
64 'user', 'changed_object_type'
65 ).filter(
66 Q(changed_object_type=content_type, changed_object_id=obj.pk) |
67 Q(related_object_type=content_type, related_object_id=obj.pk)
68 )
69 objectchanges_table = ObjectChangeTable(
70 data=objectchanges,
71 orderable=False,
72 )
73 objectchanges_table.configure(request)
75 return render(request, 'extras/object_changelog.html', {
76 'object': obj,
77 'table': objectchanges_table,
78 'base_template': self.base_template or get_default_template(model),
79 'tab': self.tab,
80 })
83class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View):
84 """
85 Render all images attached to the object as linked thumbnails.
87 Attributes:
88 base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
89 """
90 base_template = None
91 tab = ViewTab(
92 label=_('Images'),
93 badge=lambda obj: obj.images.count(),
94 permission='extras.view_imageattachment',
95 weight=6000
96 )
98 def get(self, request, model, **kwargs):
99 obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
100 image_attachments = ImageAttachment.objects.filter(
101 object_type=ContentType.objects.get_for_model(obj),
102 object_id=obj.pk,
103 )
105 return render(request, 'extras/object_imageattachments.html', {
106 'object': obj,
107 'image_attachments': image_attachments,
108 'base_template': self.base_template or get_default_template(model),
109 'tab': self.tab,
110 })
113class ObjectJournalView(ConditionalLoginRequiredMixin, View):
114 """
115 Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this
116 view in a URL path. For example:
118 path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
120 Attributes:
121 base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
122 """
123 base_template = None
124 tab = ViewTab(
125 label=_('Journal'),
126 badge=lambda obj: obj.journal_entries.count(),
127 permission='extras.view_journalentry',
128 weight=9000
129 )
131 def get(self, request, model, **kwargs):
133 # Handle QuerySet restriction of parent object if needed
134 if hasattr(model.objects, 'restrict'):
135 obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
136 else:
137 obj = get_object_or_404(model, **kwargs)
139 # Gather all changes for this object (and its related objects)
140 content_type = ContentType.objects.get_for_model(model)
141 journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
142 assigned_object_type=content_type,
143 assigned_object_id=obj.pk
144 )
145 journalentry_table = JournalEntryTable(journalentries)
146 journalentry_table.configure(request)
147 journalentry_table.columns.hide('assigned_object_type')
148 journalentry_table.columns.hide('assigned_object')
150 if request.user.has_perm('extras.add_journalentry'):
151 form = JournalEntryForm(
152 initial={
153 'assigned_object_type': ContentType.objects.get_for_model(obj),
154 'assigned_object_id': obj.pk
155 }
156 )
157 else:
158 form = None
160 return render(request, 'extras/object_journal.html', {
161 'object': obj,
162 'form': form,
163 'table': journalentry_table,
164 'base_template': self.base_template or get_default_template(model),
165 'tab': self.tab,
166 })
169class ObjectJobsView(ConditionalLoginRequiredMixin, View):
170 """
171 Render a list of all Job assigned to an object. For example:
173 path(
174 'data-sources/<int:pk>/jobs/',
175 ObjectJobsView.as_view(),
176 name='datasource_jobs',
177 kwargs={'model': DataSource}
178 )
180 Attributes:
181 base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
182 """
183 base_template = None
184 tab = ViewTab(
185 label=_('Jobs'),
186 badge=lambda obj: obj.jobs.count(),
187 permission='core.view_job',
188 weight=11000
189 )
191 def get_object(self, request, **kwargs):
192 return get_object_or_404(self.model.objects.restrict(request.user, 'view'), **kwargs)
194 def get_jobs(self, instance):
195 object_type = ContentType.objects.get_for_model(instance)
196 return Job.objects.defer('data').filter(
197 object_type=object_type,
198 object_id=instance.id
199 )
201 def get(self, request, model, **kwargs):
202 self.model = model
203 obj = self.get_object(request, **kwargs)
205 # Gather all Jobs for this object
206 jobs = self.get_jobs(obj)
207 jobs_table = JobTable(data=jobs, orderable=False)
208 jobs_table.configure(request)
210 return render(request, 'core/object_jobs.html', {
211 'object': obj,
212 'table': jobs_table,
213 'base_template': self.base_template or get_default_template(model),
214 'tab': self.tab,
215 })
218class ObjectSyncDataView(LoginRequiredMixin, View):
220 def post(self, request, model, **kwargs):
221 """
222 Synchronize data from the DataFile associated with this object.
223 """
224 qs = model.objects.all()
225 if hasattr(model.objects, 'restrict'):
226 qs = qs.restrict(request.user, 'sync')
227 obj = get_object_or_404(qs, **kwargs)
229 if not obj.data_file:
230 messages.error(request, _("Unable to synchronize data: No data file set."))
231 return redirect(obj.get_absolute_url())
233 obj.sync(save=True)
234 messages.success(request, _("Synchronized data for {object_type} {object}.").format(
235 object_type=model._meta.verbose_name,
236 object=obj
237 ))
239 return redirect(obj.get_absolute_url())
242class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
243 """
244 Synchronize multiple instances of a model inheriting from SyncedDataMixin.
245 """
246 def get_required_permission(self):
247 return get_permission_for_model(self.queryset.model, 'sync')
249 def post(self, request):
250 selected_objects = self.queryset.filter(
251 pk__in=request.POST.getlist('pk'),
252 data_file__isnull=False
253 )
255 with transaction.atomic(using=router.db_for_write(self.queryset.model)):
256 for obj in selected_objects:
257 obj.sync(save=True)
259 messages.success(request, _("Synced {count} {object_type}").format(
260 count=len(selected_objects),
261 object_type=self.queryset.model._meta.verbose_name_plural
262 ))
264 return redirect(self.get_return_url(request))
267class ObjectContactsView(ObjectChildrenView):
268 child_model = ContactAssignment
269 table = ContactAssignmentTable
270 filterset = ContactAssignmentFilterSet
271 filterset_form = ContactAssignmentFilterForm
272 template_name = 'tenancy/object_contacts.html'
273 tab = ViewTab(
274 label=_('Contacts'),
275 badge=lambda obj: obj.get_contacts().count(),
276 permission='tenancy.view_contactassignment',
277 weight=5000
278 )
280 def dispatch(self, request, *args, **kwargs):
281 model = kwargs.pop('model')
282 self.queryset = model.objects.all()
283 return super().dispatch(request, *args, **kwargs)
285 def get_children(self, request, parent):
286 return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role')