Coverage for apis_core/generic/views.py: 43%

159 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-16 07:42 +0000

1from dal import autocomplete 

2from django import forms, http 

3from django.conf import settings 

4from django.contrib.auth.mixins import PermissionRequiredMixin 

5from django.forms import modelform_factory 

6from django.template.exceptions import TemplateDoesNotExist 

7from django.template.loader import select_template 

8from django.urls import reverse, reverse_lazy 

9from django.views.generic import DetailView 

10from django.views.generic.base import TemplateView 

11from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView 

12from django_filters.views import FilterView 

13from django_tables2 import SingleTableMixin 

14from django_tables2.columns import library 

15from django_tables2.tables import table_factory 

16 

17from apis_core.core.mixins import ListViewObjectFilterMixin 

18from apis_core.utils.helpers import create_object_from_uri 

19 

20from .filtersets import GenericFilterSet, filterset_factory 

21from .forms import GenericImportForm, GenericModelForm 

22from .helpers import ( 

23 first_member_match, 

24 generate_search_filter, 

25 module_paths, 

26 permission_fullname, 

27 template_names_via_mro, 

28) 

29from .tables import GenericTable 

30 

31 

32class Overview(TemplateView): 

33 template_name = "generic/overview.html" 

34 

35 

36class GenericModelMixin: 

37 """ 

38 A mixin providing the common functionality for all the views working 

39 with `generic` models - that is models that are accessed via the 

40 contenttype framework (using `app_label.model`). 

41 It sets the `.model` of the view and generates a list of possible template 

42 names (based on the MRO of the model). 

43 If the view has a `permission_action_required` attribute, this is used 

44 to set the permission required to access the view for this specific model. 

45 """ 

46 

47 def setup(self, *args, **kwargs): 

48 super().setup(*args, **kwargs) 

49 if contenttype := kwargs.get("contenttype"): 

50 self.model = contenttype.model_class() 

51 self.queryset = self.model.objects.all() 

52 

53 def get_template_names(self): 

54 template_names = [] 

55 if hasattr(super(), "get_template_names"): 

56 template_names = super().get_template_names() 

57 suffix = ".html" 

58 if hasattr(self, "template_name_suffix"): 

59 suffix = self.template_name_suffix + ".html" 

60 additional_templates = template_names_via_mro(self.model, suffix) + [ 

61 f"generic/generic{suffix}" 

62 ] 

63 template_names += filter( 

64 lambda template: template not in template_names, additional_templates 

65 ) 

66 return template_names 

67 

68 def get_permission_required(self): 

69 if hasattr(settings, "APIS_VIEW_PASSES_TEST"): 

70 if settings.APIS_VIEW_PASSES_TEST(self): 

71 return [] 

72 if hasattr(self, "permission_action_required"): 

73 return [permission_fullname(self.permission_action_required, self.model)] 

74 return [] 

75 

76 

77class List( 

78 ListViewObjectFilterMixin, 

79 GenericModelMixin, 

80 PermissionRequiredMixin, 

81 SingleTableMixin, 

82 FilterView, 

83): 

84 """ 

85 List view for a generic model. 

86 Access requires the `<model>_view` permission. 

87 It is based on django-filters FilterView and django-tables SingleTableMixin. 

88 The table class is overridden by the first match from 

89 the `first_member_match` helper. 

90 The filterset class is overridden by the first match from 

91 the `first_member_match` helper. 

92 The queryset is overridden by the first match from 

93 the `first_member_match` helper. 

94 """ 

95 

96 template_name_suffix = "_list" 

97 permission_action_required = "view" 

98 

99 def get_table_class(self): 

100 table_modules = module_paths(self.model, path="tables", suffix="Table") 

101 table_class = first_member_match(table_modules, GenericTable) 

102 return table_factory(self.model, table_class) 

103 

104 def get_table_kwargs(self): 

105 kwargs = super().get_table_kwargs() 

106 

107 # we look at the selected columns and exclude 

108 # all modelfields that are not part of that list 

109 selected_columns = self.request.GET.getlist( 

110 "columns", 

111 self.get_filterset(self.get_filterset_class()).form["columns"].initial, 

112 ) 

113 modelfields = self.model._meta.get_fields() 

114 kwargs["exclude"] = [ 

115 field.name for field in modelfields if field.name not in selected_columns 

116 ] 

117 

118 # now we look at the selected columns and 

119 # add all modelfields and annotated fields that 

120 # are part of the selected columns to the extra_columns 

121 annotationfields = list() 

122 for key, value in self.object_list.query.annotations.items(): 

123 fake_field = getattr(value, "field", value.output_field) 

124 setattr(fake_field, "name", key) 

125 annotationfields.append(fake_field) 

126 extra_fields = list( 

127 filter( 

128 lambda x: x.name in selected_columns, 

129 modelfields + tuple(annotationfields), 

130 ) 

131 ) 

132 kwargs["extra_columns"] = [ 

133 (field.name, library.column_for_field(field, accessor=field.name)) 

134 for field in extra_fields 

135 if field.name not in self.get_table_class().base_columns 

136 ] 

137 

138 return kwargs 

139 

140 def get_filterset_class(self): 

141 filterset_modules = module_paths( 

142 self.model, path="filtersets", suffix="FilterSet" 

143 ) 

144 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

145 return filterset_factory(self.model, filterset_class) 

146 

147 def _get_columns_choices(self, columns_exclude): 

148 # we start with the model fields 

149 choices = [ 

150 (field.name, getattr(field, "verbose_name", field.name)) 

151 for field in self.model._meta.get_fields() 

152 ] 

153 # we add any annotated fields to that 

154 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()] 

155 # now we drop all the choices that are listed in columns_exclude 

156 choices = list(filter(lambda x: x[0] not in columns_exclude, choices)) 

157 return choices 

158 

159 def _get_columns_initial(self, columns_exclude): 

160 return [ 

161 field 

162 for field in self.get_table().columns.names() 

163 if field not in columns_exclude 

164 ] 

165 

166 def get_filterset(self, filterset_class): 

167 """ 

168 We override the `get_filterset` method, so we can inject a 

169 `columns` selector into the form 

170 """ 

171 filterset = super().get_filterset(filterset_class) 

172 columns_exclude = filterset.form.columns_exclude 

173 

174 # we inject a `columns` selector in the beginning of the form 

175 columns = forms.MultipleChoiceField( 

176 required=False, 

177 choices=self._get_columns_choices(columns_exclude), 

178 initial=self._get_columns_initial(columns_exclude), 

179 ) 

180 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields} 

181 

182 return filterset 

183 

184 def get_queryset(self): 

185 queryset_methods = module_paths( 

186 self.model, path="querysets", suffix="ListViewQueryset" 

187 ) 

188 queryset = first_member_match(queryset_methods) or (lambda x: x) 

189 return self.filter_queryset(queryset(self.model.objects.all())) 

190 

191 

192class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

193 """ 

194 Detail view for a generic model. 

195 Access requires the `<model>_view` permission. 

196 """ 

197 

198 permission_action_required = "view" 

199 

200 

201class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): 

202 """ 

203 Create view for a generic model. 

204 Access requires the `<model>_add` permission. 

205 The form class is overridden by the first match from 

206 the `first_member_match` helper. 

207 """ 

208 

209 template_name = "generic/generic_form.html" 

210 permission_action_required = "add" 

211 

212 def get_form_class(self): 

213 form_modules = module_paths(self.model, path="forms", suffix="Form") 

214 form_class = first_member_match(form_modules, GenericModelForm) 

215 return modelform_factory(self.model, form_class) 

216 

217 def get_success_url(self): 

218 return self.object.get_create_success_url() 

219 

220 

221class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

222 """ 

223 Delete view for a generic model. 

224 Access requires the `<model>_delete` permission. 

225 """ 

226 

227 permission_action_required = "delete" 

228 

229 def get_success_url(self): 

230 return reverse( 

231 "apis_core:generic:list", 

232 args=[self.request.resolver_match.kwargs["contenttype"]], 

233 ) 

234 

235 def delete(self, *args, **kwargs): 

236 if "HX-Request" in self.request.headers: 

237 return ( 

238 reverse_lazy( 

239 "apis_core:generic:list", 

240 args=[self.request.resolver_match.kwargs["contenttype"]], 

241 ), 

242 ) 

243 return super().delete(*args, **kwargs) 

244 

245 

246class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): 

247 """ 

248 Update view for a generic model. 

249 Access requires the `<model>_change` permission. 

250 The form class is overridden by the first match from 

251 the `first_member_match` helper. 

252 """ 

253 

254 permission_action_required = "change" 

255 

256 def get_form_class(self): 

257 form_modules = module_paths(self.model, path="forms", suffix="Form") 

258 form_class = first_member_match(form_modules, GenericModelForm) 

259 return modelform_factory(self.model, form_class) 

260 

261 def get_success_url(self): 

262 return self.object.get_update_success_url() 

263 

264 

265class Autocomplete( 

266 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

267): 

268 """ 

269 Autocomplete view for a generic model. 

270 Access requires the `<model>_view` permission. 

271 The queryset is overridden by the first match from 

272 the `first_member_match` helper. 

273 """ 

274 

275 permission_action_required = "view" 

276 template_name_suffix = "_autocomplete_result" 

277 create_field = "thisisnotimportant" # because we are using create_object_from_uri 

278 

279 def setup(self, *args, **kwargs): 

280 super().setup(*args, **kwargs) 

281 try: 

282 template = select_template(self.get_template_names()) 

283 self.template = template.template.name 

284 except TemplateDoesNotExist: 

285 self.template = None 

286 

287 def get_queryset(self): 

288 queryset_methods = module_paths( 

289 self.model, path="querysets", suffix="AutocompleteQueryset" 

290 ) 

291 queryset = first_member_match(queryset_methods) 

292 if queryset: 

293 return queryset(self.model, self.q) 

294 return self.model.objects.filter(generate_search_filter(self.model, self.q)) 

295 

296 def get_results(self, context): 

297 external_only = self.kwargs.get("external_only", False) 

298 results = [] if external_only else super().get_results(context) 

299 queryset_methods = module_paths( 

300 self.model, path="querysets", suffix="ExternalAutocomplete" 

301 ) 

302 ExternalAutocomplete = first_member_match(queryset_methods) 

303 if ExternalAutocomplete: 

304 results.extend(ExternalAutocomplete().get_results(self.q)) 

305 return results 

306 

307 def create_object(self, value): 

308 return create_object_from_uri(value, self.queryset.model) 

309 

310 def post(self, request, *args, **kwargs): 

311 try: 

312 return super().post(request, *args, **kwargs) 

313 except Exception as e: 

314 return http.JsonResponse({"error": str(e)}) 

315 

316 

317class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

318 template_name = "generic/generic_import_form.html" 

319 template_name_suffix = "_import" 

320 permission_action_required = "add" 

321 

322 def get_form_class(self): 

323 form_modules = module_paths(self.model, path="forms", suffix="ImportForm") 

324 form_class = first_member_match(form_modules, GenericImportForm) 

325 return modelform_factory(self.model, form_class) 

326 

327 def form_valid(self, form): 

328 self.object = form.cleaned_data["url"] 

329 return super().form_valid(form) 

330 

331 def get_success_url(self): 

332 return self.object.get_absolute_url()