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

242 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-22 07:51 +0000

1from collections import namedtuple 

2from copy import copy 

3from typing import Optional 

4 

5from dal import autocomplete 

6from django import forms, http 

7from django.conf import settings 

8from django.contrib import messages 

9from django.contrib.auth.mixins import PermissionRequiredMixin 

10from django.core.exceptions import ImproperlyConfigured 

11from django.forms import modelform_factory 

12from django.forms.utils import pretty_name 

13from django.shortcuts import get_object_or_404, redirect 

14from django.template.exceptions import TemplateDoesNotExist 

15from django.template.loader import select_template 

16from django.urls import reverse, reverse_lazy 

17from django.views.generic import DetailView 

18from django.views.generic.base import TemplateView 

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

20from django_filters.filterset import filterset_factory 

21from django_filters.views import FilterView 

22from django_tables2 import SingleTableMixin 

23from django_tables2.columns import library 

24from django_tables2.tables import table_factory 

25 

26from apis_core.apis_metainfo.models import Uri 

27from apis_core.core.mixins import ListViewObjectFilterMixin 

28from apis_core.utils.helpers import create_object_from_uri, get_importer_for_model 

29 

30from .filtersets import GenericFilterSet 

31from .forms import ( 

32 GenericEnrichForm, 

33 GenericImportForm, 

34 GenericMergeForm, 

35 GenericModelForm, 

36) 

37from .helpers import ( 

38 first_member_match, 

39 generate_search_filter, 

40 module_paths, 

41 permission_fullname, 

42 template_names_via_mro, 

43) 

44from .tables import GenericTable 

45 

46 

47class Overview(TemplateView): 

48 template_name = "generic/overview.html" 

49 

50 

51class GenericModelMixin: 

52 """ 

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

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

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

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

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

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

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

60 """ 

61 

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

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

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

65 self.model = contenttype.model_class() 

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

67 

68 def get_template_names(self): 

69 template_names = [] 

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

71 template_names = super().get_template_names() 

72 suffix = ".html" 

73 if hasattr(self, "template_name_suffix"): 

74 suffix = self.template_name_suffix + ".html" 

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

76 f"generic/generic{suffix}" 

77 ] 

78 template_names += filter( 

79 lambda template: template not in template_names, additional_templates 

80 ) 

81 return template_names 

82 

83 def get_permission_required(self): 

84 if hasattr(settings, "APIS_VIEW_PASSES_TEST"): 

85 if settings.APIS_VIEW_PASSES_TEST(self): 

86 return [] 

87 if hasattr(self, "permission_action_required"): 

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

89 return [] 

90 

91 

92class List( 

93 ListViewObjectFilterMixin, 

94 GenericModelMixin, 

95 PermissionRequiredMixin, 

96 SingleTableMixin, 

97 FilterView, 

98): 

99 """ 

100 List view for a generic model. 

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

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

103 The table class is overridden by the first match from 

104 the `first_member_match` helper. 

105 The filterset class is overridden by the first match from 

106 the `first_member_match` helper. 

107 The queryset is overridden by the first match from 

108 the `first_member_match` helper. 

109 """ 

110 

111 template_name_suffix = "_list" 

112 permission_action_required = "view" 

113 

114 def get_table_class(self): 

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

116 table_class = first_member_match(table_modules, GenericTable) 

117 return table_factory(self.model, table_class) 

118 

119 def get_table_kwargs(self): 

120 kwargs = super().get_table_kwargs() 

121 

122 # we look at the selected columns and exclude 

123 # all modelfields that are not part of that list 

124 selected_columns = self.request.GET.getlist( 

125 "columns", 

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

127 ) 

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

129 kwargs["exclude"] = [ 

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

131 ] 

132 

133 # now we look at the selected columns and 

134 # add all modelfields and annotated fields that 

135 # are part of the selected columns to the extra_columns 

136 annotationfields = list() 

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

138 # we have to use copy, so we don't edit the original field 

139 fake_field = copy(getattr(value, "field", value.output_field)) 

140 setattr(fake_field, "name", key) 

141 annotationfields.append(fake_field) 

142 extra_fields = list( 

143 filter( 

144 lambda x: x.name in selected_columns, 

145 modelfields + tuple(annotationfields), 

146 ) 

147 ) 

148 kwargs["extra_columns"] = [ 

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

150 for field in extra_fields 

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

152 ] 

153 

154 return kwargs 

155 

156 def get_filterset_class(self): 

157 filterset_modules = module_paths( 

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

159 ) 

160 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

161 return filterset_factory(self.model, filterset_class) 

162 

163 def _get_columns_choices(self, columns_exclude): 

164 # we start with the model fields 

165 choices = [ 

166 (field.name, pretty_name(getattr(field, "verbose_name", field.name))) 

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

168 ] 

169 # we add any annotated fields to that 

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

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

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

173 return choices 

174 

175 def _get_columns_initial(self, columns_exclude): 

176 return [ 

177 field 

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

179 if field not in columns_exclude 

180 ] 

181 

182 def get_filterset(self, filterset_class): 

183 """ 

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

185 `columns` selector into the form 

186 """ 

187 filterset = super().get_filterset(filterset_class) 

188 columns_exclude = filterset.form.columns_exclude 

189 

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

191 columns = forms.MultipleChoiceField( 

192 required=False, 

193 choices=self._get_columns_choices(columns_exclude), 

194 initial=self._get_columns_initial(columns_exclude), 

195 ) 

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

197 

198 return filterset 

199 

200 def get_queryset(self): 

201 queryset_methods = module_paths( 

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

203 ) 

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

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

206 

207 def get_paginate_by(self, table_data) -> Optional[int]: 

208 """ 

209 Override `get_paginate_by` from the tables2 TableMixinBase, 

210 so we can set the paginate_by value as attribute of the table. 

211 """ 

212 return getattr(self.get_table_class(), "paginate_by", None) 

213 

214 

215class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

216 """ 

217 Detail view for a generic model. 

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

219 """ 

220 

221 permission_action_required = "view" 

222 

223 

224class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): 

225 """ 

226 Create view for a generic model. 

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

228 The form class is overridden by the first match from 

229 the `first_member_match` helper. 

230 """ 

231 

232 template_name = "generic/generic_form.html" 

233 permission_action_required = "add" 

234 

235 def get_form_class(self): 

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

237 form_class = first_member_match(form_modules, GenericModelForm) 

238 return modelform_factory(self.model, form_class) 

239 

240 def get_success_url(self): 

241 return self.object.get_create_success_url() 

242 

243 

244class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

245 """ 

246 Delete view for a generic model. 

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

248 """ 

249 

250 permission_action_required = "delete" 

251 

252 def get_success_url(self): 

253 return reverse( 

254 "apis_core:generic:list", 

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

256 ) 

257 

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

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

260 return ( 

261 reverse_lazy( 

262 "apis_core:generic:list", 

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

264 ), 

265 ) 

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

267 

268 

269class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): 

270 """ 

271 Update view for a generic model. 

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

273 The form class is overridden by the first match from 

274 the `first_member_match` helper. 

275 """ 

276 

277 permission_action_required = "change" 

278 

279 def get_form_class(self): 

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

281 form_class = first_member_match(form_modules, GenericModelForm) 

282 return modelform_factory(self.model, form_class) 

283 

284 def get_success_url(self): 

285 return self.object.get_update_success_url() 

286 

287 

288class Autocomplete( 

289 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

290): 

291 """ 

292 Autocomplete view for a generic model. 

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

294 The queryset is overridden by the first match from 

295 the `first_member_match` helper. 

296 """ 

297 

298 permission_action_required = "view" 

299 template_name_suffix = "_autocomplete_result" 

300 

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

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

303 # We use a URI parameter to enable the create functionality in the 

304 # autocomplete dropdown. It is not important what the value of the 

305 # `create_field` is, because we use create_object_from_uri anyway. 

306 self.create_field = self.request.GET.get("create", None) 

307 try: 

308 template = select_template(self.get_template_names()) 

309 self.template = template.template.name 

310 except TemplateDoesNotExist: 

311 self.template = None 

312 

313 def get_queryset(self): 

314 queryset_methods = module_paths( 

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

316 ) 

317 queryset = first_member_match(queryset_methods) 

318 if queryset: 

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

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

321 

322 def get_results(self, context): 

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

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

325 queryset_methods = module_paths( 

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

327 ) 

328 ExternalAutocomplete = first_member_match(queryset_methods) 

329 if ExternalAutocomplete: 

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

331 return results 

332 

333 def create_object(self, value): 

334 return create_object_from_uri(value, self.queryset.model, raise_on_fail=True) 

335 

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

337 try: 

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

339 except Exception as e: 

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

341 

342 

343class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

344 template_name = "generic/generic_import_form.html" 

345 template_name_suffix = "_import" 

346 permission_action_required = "add" 

347 

348 def get_form_class(self): 

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

350 form_class = first_member_match(form_modules, GenericImportForm) 

351 return modelform_factory(self.model, form_class) 

352 

353 def form_valid(self, form): 

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

355 return super().form_valid(form) 

356 

357 def get_success_url(self): 

358 return self.object.get_absolute_url() 

359 

360 

361class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

362 """ 

363 Generic merge view. 

364 """ 

365 

366 permission_action_required = "change" 

367 form_class = GenericMergeForm 

368 template_name = "generic/generic_merge.html" 

369 

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

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

372 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"]) 

373 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"]) 

374 

375 def get_context_data(self, **kwargs): 

376 """ 

377 The context consists of the two objects that are merged as well 

378 as a list of changes. Those changes are presented in the view as 

379 a table with diffs 

380 """ 

381 Change = namedtuple("Change", "field old new") 

382 ctx = super().get_context_data(**kwargs) 

383 ctx["changes"] = [] 

384 for field in self.object._meta.fields: 

385 newval = self.object.get_field_value_after_merge(self.other, field) 

386 ctx["changes"].append( 

387 Change(field.verbose_name, getattr(self.object, field.name), newval) 

388 ) 

389 ctx["object"] = self.object 

390 ctx["other"] = self.other 

391 return ctx 

392 

393 def form_valid(self, form): 

394 self.object.merge_with([self.other]) 

395 messages.info(self.request, f"Merged values of {self.other} into {self.object}") 

396 return super().form_valid(form) 

397 

398 def get_success_url(self): 

399 return self.object.get_absolute_url() 

400 

401 

402class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

403 """ 

404 Enrich an entity with data from an external source 

405 If so, it uses the proper Importer to get the data from the Uri and 

406 provides the user with a form to select the fields that should be updated. 

407 """ 

408 

409 permission_action_required = "change" 

410 template_name = "generic/generic_enrich.html" 

411 form_class = GenericEnrichForm 

412 importer_class = None 

413 

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

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

416 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"]) 

417 self.uri = self.request.GET.get("uri") 

418 if not self.uri: 

419 messages.error(self.request, "No uri parameter specified.") 

420 self.importer_class = get_importer_for_model(self.model) 

421 

422 def get(self, *args, **kwargs): 

423 if self.uri.isdigit(): 

424 return redirect(self.object.get_merge_url(self.uri)) 

425 try: 

426 uriobj = Uri.objects.get(uri=self.uri) 

427 if uriobj.root_object.id != self.object.id: 

428 messages.info( 

429 self.request, 

430 f"Object with URI {self.uri} already exists, you were redirected to the merge form.", 

431 ) 

432 return redirect(self.object.get_merge_url(uriobj.root_object.id)) 

433 except Uri.DoesNotExist: 

434 pass 

435 return super().get(*args, **kwargs) 

436 

437 def get_context_data(self, **kwargs): 

438 ctx = super().get_context_data(**kwargs) 

439 ctx["object"] = self.object 

440 ctx["uri"] = self.uri 

441 return ctx 

442 

443 def get_form_kwargs(self, *args, **kwargs): 

444 kwargs = super().get_form_kwargs(*args, **kwargs) 

445 kwargs["instance"] = self.object 

446 try: 

447 importer = self.importer_class(self.uri, self.model) 

448 kwargs["data"] = importer.get_data() 

449 except ImproperlyConfigured as e: 

450 messages.error(self.request, e) 

451 return kwargs 

452 

453 def form_valid(self, form): 

454 """ 

455 Go through all the form fields and extract the ones that 

456 start with `update_` and that are set (those are the checkboxes that 

457 select which fields to update). 

458 Then use the importers `import_into_instance` method to set those 

459 fields values on the model instance. 

460 """ 

461 update_fields = [ 

462 key.removeprefix("update_") 

463 for (key, value) in self.request.POST.items() 

464 if key.startswith("update_") and value 

465 ] 

466 importer = self.importer_class(self.uri, self.model) 

467 importer.import_into_instance(self.object, fields=update_fields) 

468 messages.info(self.request, f"Updated fields {update_fields}") 

469 uri, created = Uri.objects.get_or_create(uri=self.uri, root_object=self.object) 

470 if created: 

471 messages.info(self.request, f"Added uri {self.uri} to {self.object}") 

472 return super().form_valid(form) 

473 

474 def get_success_url(self): 

475 return self.object.get_absolute_url()