Coverage for sm / views.py: 49%

144 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-24 12:43 +0000

1from django.views.generic import TemplateView 

2from django.contrib.auth.mixins import LoginRequiredMixin 

3from django.contrib import messages 

4from server.models import Model as Server 

5from cluster.models import Model as Cluster 

6from vendor.models import Model as Vendor 

7from operatingsystem.models import Model as OS 

8from django.db.models import Count, Q 

9from django.core.exceptions import ObjectDoesNotExist 

10from django.apps import apps 

11from django.http import Http404, HttpResponseRedirect 

12 

13from django.db.models import ProtectedError 

14from django.utils.translation import gettext as _ 

15from django.shortcuts import redirect 

16from django.views import View 

17from typing import Any, List 

18from .utils_starterpack import import_starter_pack 

19 

20 

21class SafeDeleteMixin: 

22 """ 

23 Mixin to catch ProtectedError during deletion and offer 

24 reassignment or bulk deletion. 

25 """ 

26 

27 def get_context_data(self, **kwargs: Any) -> Any: 

28 context = super().get_context_data(**kwargs) # type: ignore 

29 if hasattr(self, "protected_error") and self.protected_error: # type: ignore 

30 context["protected_error"] = True 

31 # Exclude our object from reassign list 

32 context["all_objects"] = self.model.objects.exclude( 

33 pk=self.object.pk # type: ignore 

34 ) 

35 

36 # Re-collect protected objects properly 

37 try: 

38 self.object.delete() # type: ignore 

39 except ProtectedError as e: 

40 context["protected_objects"] = e.protected_objects 

41 context["protected_count"] = len(e.protected_objects) 

42 return context 

43 

44 def form_valid(self, form: Any) -> Any: 

45 success_url = self.get_success_url() # type: ignore 

46 try: 

47 # Try normal deletion first 

48 obj_name = str(self.object) # type: ignore 

49 self.object.delete() # type: ignore 

50 if hasattr(self, "success_message") and self.success_message: # type: ignore # noqa: E501 

51 messages.success( 

52 # type: ignore 

53 self.request, 

54 self.success_message % self.object.__dict__, 

55 ) 

56 else: 

57 messages.success(self.request, _("Successfully deleted %s") % obj_name) 

58 return HttpResponseRedirect(success_url) 

59 except ProtectedError as e: 

60 action = self.request.POST.get("protected_action") 

61 if action == "reassign": 

62 new_obj_id = self.request.POST.get("new_target") 

63 if new_obj_id: 

64 new_obj = self.model.objects.get(pk=new_obj_id) # type: ignore 

65 # This part is tricky as we don't know the field name on 

66 # the remote side without inspecting the protected objects. 

67 for protected in e.protected_objects: 

68 # Find the FK field that points to our object 

69 for field in protected._meta.fields: 

70 # type: ignore 

71 if field.is_relation and field.related_model == self.model: 

72 setattr(protected, field.name, new_obj) 

73 protected.save() 

74 

75 self.object.delete() # type: ignore 

76 messages.success( 

77 self.request, 

78 _("Successfully reassigned dependencies and deleted %s") 

79 % self.object, # type: ignore 

80 ) 

81 return HttpResponseRedirect(success_url) 

82 

83 elif action == "delete_all": 

84 for protected in e.protected_objects: 

85 protected.delete() 

86 self.object.delete() # type: ignore 

87 messages.success( 

88 self.request, 

89 _("Successfully deleted %s and all dependent objects") 

90 % self.object, # type: ignore 

91 ) 

92 return HttpResponseRedirect(success_url) 

93 

94 self.protected_error = True # type: ignore 

95 return self.render_to_response( 

96 self.get_context_data(object=self.object) # type: ignore 

97 ) 

98 

99 

100class DashboardView(LoginRequiredMixin, TemplateView): 

101 template_name = "dashboard.html" 

102 

103 def get_queryset_filtered(self, model: Any) -> Any: 

104 if self.request.user.is_superuser: 

105 return model.objects.all() 

106 user_groups = self.request.user.groups.all() 

107 if hasattr(model, "group"): 

108 return model.objects.filter( 

109 Q(group__in=user_groups) | Q(group__isnull=True) 

110 ) 

111 return model.objects.all() 

112 

113 def get_context_data(self, **kwargs: Any) -> Any: 

114 context = super().get_context_data(**kwargs) 

115 

116 # Basic Stats (Filtered) 

117 context["server_count"] = self.get_queryset_filtered(Server).count() 

118 context["cluster_count"] = self.get_queryset_filtered(Cluster).count() 

119 context["vendor_count"] = Vendor.objects.count() 

120 context["os_count"] = OS.objects.count() 

121 

122 # Data for Charts (Filtered) 

123 # OS Distribution 

124 os_dist = ( 

125 self.get_queryset_filtered(Server) 

126 .values("operatingsystem__vendor__name", "operatingsystem__version") 

127 .annotate(count=Count("id")) 

128 .order_by("-count")[:5] 

129 ) 

130 

131 context["os_labels"] = [ 

132 f"{item['operatingsystem__vendor__name']} " 

133 f"{item['operatingsystem__version']}" 

134 for item in os_dist 

135 ] 

136 context["os_data"] = [item["count"] for item in os_dist] 

137 

138 # Status Distribution 

139 status_dist = ( 

140 self.get_queryset_filtered(Server) 

141 .values("status__name") 

142 .annotate(count=Count("id")) 

143 .order_by("-count") 

144 ) 

145 

146 context["status_labels"] = [item["status__name"] for item in status_dist] 

147 context["status_data"] = [item["count"] for item in status_dist] 

148 

149 # Recent Activity (Filtered) 

150 context["recent_servers"] = ( 

151 self.get_queryset_filtered(Server).all().order_by("-id")[:5] 

152 ) 

153 

154 return context 

155 

156 

157class SearchView(LoginRequiredMixin, TemplateView): 

158 template_name = "search.html" 

159 

160 def get_queryset_filtered(self, model: Any) -> Any: 

161 if self.request.user.is_superuser: 

162 return model.objects.all() 

163 user_groups = self.request.user.groups.all() 

164 if hasattr(model, "group"): 

165 return model.objects.filter( 

166 Q(group__in=user_groups) | Q(group__isnull=True) 

167 ) 

168 return model.objects.all() 

169 

170 def get_template_names(self) -> List[str]: 

171 if self.request.GET.get("ajax"): 

172 return ["search_results_ajax.html"] 

173 return [self.template_name] 

174 

175 def get_context_data(self, **kwargs: Any) -> Any: 

176 context = super().get_context_data(**kwargs) 

177 query = self.request.GET.get("q", "").lower() 

178 context["query"] = query 

179 

180 # Navigation Quick Jumps 

181 nav_targets = [ 

182 {"name": _("Dashboard"), "url": "/", "icon": "fa-gauge-high"}, 

183 { 

184 "name": _("Group Management"), 

185 "url": "/group/members/", 

186 "icon": "fa-users", 

187 }, 

188 {"name": _("Servers"), "url": "/server/", "icon": "fa-server"}, 

189 {"name": _("Server Models"), "url": "/servermodel/", "icon": "fa-cubes"}, 

190 {"name": _("Vendors"), "url": "/vendor/", "icon": "fa-industry"}, 

191 {"name": _("Clusters"), "url": "/cluster/", "icon": "fa-th-large"}, 

192 { 

193 "name": _("Operating Systems"), 

194 "url": "/operatingsystem/", 

195 "icon": "fa-laptop", 

196 }, 

197 {"name": _("Statuses"), "url": "/status/", "icon": "fa-tag"}, 

198 {"name": _("Locations"), "url": "/location/", "icon": "fa-map-marker-alt"}, 

199 {"name": _("Domains"), "url": "/domain/", "icon": "fa-globe"}, 

200 {"name": _("Patch Times"), "url": "/patchtime/", "icon": "fa-calendar"}, 

201 { 

202 "name": _("Cluster Software"), 

203 "url": "/clustersoftware/", 

204 "icon": "fa-shield-halved", 

205 }, 

206 { 

207 "name": _("Cluster Packages"), 

208 "url": "/clusterpackage/", 

209 "icon": "fa-archive", 

210 }, 

211 { 

212 "name": _("API Documentation"), 

213 "url": "/api/schema/swagger-ui/", 

214 "icon": "fa-book", 

215 }, 

216 ] 

217 

218 if len(query) >= 2: 

219 # Filter navigation targets 

220 context["nav_results"] = [ 

221 item for item in nav_targets if query in (item["name"].lower()) 

222 ] 

223 

224 context["servers"] = self.get_queryset_filtered(Server).filter( 

225 hostname__icontains=query 

226 )[:10] 

227 context["vendors"] = Vendor.objects.filter(name__icontains=query)[:10] 

228 context["clusters"] = self.get_queryset_filtered(Cluster).filter( 

229 name__icontains=query 

230 )[:10] 

231 

232 # Simple check if anything was found 

233 context["has_results"] = any( 

234 [ 

235 context["nav_results"], 

236 context["servers"].exists(), 

237 context["vendors"].exists(), 

238 context["clusters"].exists(), 

239 ] 

240 ) 

241 else: 

242 context["has_results"] = False 

243 context["query_too_short"] = True 

244 

245 return context 

246 

247 

248class HistoryDiffView(LoginRequiredMixin, TemplateView): 

249 template_name = "history_diff.html" 

250 

251 def get_context_data(self, **kwargs: Any) -> Any: 

252 context = super().get_context_data(**kwargs) 

253 app_label = kwargs.get("app_label") 

254 history_id = kwargs.get("history_id") 

255 

256 try: 

257 model = apps.get_model(app_label, "Model") 

258 record = model.history.get(history_id=history_id) # type: ignore 

259 except (LookupError, ObjectDoesNotExist): 

260 raise Http404("History record not found") 

261 

262 context["record"] = record 

263 context["instance"] = record.instance 

264 context["app_label"] = app_label 

265 

266 if record.prev_record: 

267 context["diff"] = record.diff_against(record.prev_record) 

268 else: 

269 context["diff"] = None 

270 

271 return context 

272 

273 

274class ImportStarterPackView(LoginRequiredMixin, View): 

275 def post(self, request: Any, *args: Any, **kwargs: Any) -> Any: 

276 user_groups = request.user.groups.all() 

277 if not user_groups.exists(): 

278 messages.error(request, _("You are not assigned to any group.")) 

279 return redirect("dashboard") 

280 

281 group = user_groups.first() 

282 results = import_starter_pack(group) 

283 

284 messages.success( 

285 request, 

286 _("Imported %d vendors and %d operating systems into group %s.") 

287 % (results["vendors"], results["os"], group.name), 

288 ) 

289 return redirect("vendor:index") 

290 

291 

292class TermsView(TemplateView): 

293 template_name = "legal/terms.html" 

294 

295 

296class PrivacyView(TemplateView): 

297 template_name = "legal/privacy.html" 

298 

299 

300class ImpressumView(TemplateView): 

301 template_name = "legal/impressum.html"