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

254 statements  

« prev     ^ index     » next       coverage.py v7.6.8, created at 2024-12-20 09:24 +0000

1from collections import namedtuple 

2from copy import copy 

3 

4from dal import autocomplete 

5from django import forms, http 

6from django.conf import settings 

7from django.contrib import messages 

8from django.contrib.auth.mixins import PermissionRequiredMixin 

9from django.core.exceptions import ImproperlyConfigured 

10from django.forms import modelform_factory 

11from django.forms.utils import pretty_name 

12from django.shortcuts import get_object_or_404, redirect 

13from django.template.exceptions import TemplateDoesNotExist 

14from django.template.loader import select_template 

15from django.urls import reverse, reverse_lazy 

16from django.views.generic import DetailView 

17from django.views.generic.base import TemplateView 

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

19from django_filters.filterset import filterset_factory 

20from django_filters.views import FilterView 

21from django_tables2 import SingleTableMixin 

22from django_tables2.columns import library 

23from django_tables2.tables import table_factory 

24 

25from apis_core.apis_metainfo.models import Uri 

26from apis_core.utils.helpers import create_object_from_uri, get_importer_for_model 

27 

28from .filtersets import GenericFilterSet 

29from .forms import ( 

30 GenericEnrichForm, 

31 GenericImportForm, 

32 GenericMergeWithForm, 

33 GenericModelForm, 

34 GenericSelectMergeOrEnrichForm, 

35) 

36from .helpers import ( 

37 first_member_match, 

38 generate_search_filter, 

39 module_paths, 

40 permission_fullname, 

41 template_names_via_mro, 

42) 

43from .tables import GenericTable 

44 

45 

46class Overview(TemplateView): 

47 template_name = "generic/overview.html" 

48 

49 

50class GenericModelMixin: 

51 """ 

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

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

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

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

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

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

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

59 """ 

60 

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

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

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

64 self.model = contenttype.model_class() 

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

66 

67 def get_template_names(self): 

68 template_names = [] 

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

70 # Some parent classes come with custom template_names, 

71 # some need a `.template_name` attribute set. For the 

72 # latter ones we handle the missing `.template_name` 

73 # gracefully 

74 try: 

75 template_names = super().get_template_names() 

76 except ImproperlyConfigured: 

77 pass 

78 suffix = ".html" 

79 if hasattr(self, "template_name_suffix"): 

80 suffix = self.template_name_suffix + ".html" 

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

82 f"generic/generic{suffix}" 

83 ] 

84 template_names += filter( 

85 lambda template: template not in template_names, additional_templates 

86 ) 

87 return template_names 

88 

89 def get_permission_required(self): 

90 if getattr(self, "permission_action_required", None) == "view" and getattr( 

91 settings, "APIS_ANON_VIEWS_ALLOWED", False 

92 ): 

93 return [] 

94 if hasattr(self, "permission_action_required"): 

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

96 return [] 

97 

98 

99class List( 

100 GenericModelMixin, 

101 PermissionRequiredMixin, 

102 SingleTableMixin, 

103 FilterView, 

104): 

105 """ 

106 List view for a generic model. 

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

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

109 The table class is overridden by the first match from 

110 the `first_member_match` helper. 

111 The filterset class is overridden by the first match from 

112 the `first_member_match` helper. 

113 The queryset is overridden by the first match from 

114 the `first_member_match` helper. 

115 """ 

116 

117 template_name_suffix = "_list" 

118 permission_action_required = "view" 

119 

120 def get_table_class(self): 

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

122 table_class = first_member_match(table_modules, GenericTable) 

123 return table_factory(self.model, table_class) 

124 

125 def get_table_kwargs(self): 

126 kwargs = super().get_table_kwargs() 

127 

128 # we look at the selected columns and exclude 

129 # all modelfields that are not part of that list 

130 selected_columns = self.request.GET.getlist( 

131 "columns", 

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

133 ) 

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

135 kwargs["exclude"] = [ 

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

137 ] 

138 

139 # now we look at the selected columns and 

140 # add all modelfields and annotated fields that 

141 # are part of the selected columns to the extra_columns 

142 annotationfields = list() 

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

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

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

146 setattr(fake_field, "name", key) 

147 annotationfields.append(fake_field) 

148 extra_fields = list( 

149 filter( 

150 lambda x: x.name in selected_columns, 

151 modelfields + tuple(annotationfields), 

152 ) 

153 ) 

154 kwargs["extra_columns"] = [ 

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

156 for field in extra_fields 

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

158 ] 

159 

160 return kwargs 

161 

162 def get_filterset_class(self): 

163 filterset_modules = module_paths( 

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

165 ) 

166 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

167 return filterset_factory(self.model, filterset_class) 

168 

169 def _get_columns_choices(self, columns_exclude): 

170 # we start with the model fields 

171 choices = [ 

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

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

174 if field.name not in getattr(self.get_queryset(), "subclasses", []) 

175 ] 

176 # we add any annotated fields to that 

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

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

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

180 return choices 

181 

182 def _get_columns_initial(self, columns_exclude): 

183 return [ 

184 field 

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

186 if field not in columns_exclude 

187 ] 

188 

189 def get_filterset(self, filterset_class): 

190 """ 

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

192 `columns` selector into the form 

193 """ 

194 filterset = super().get_filterset(filterset_class) 

195 columns_exclude = filterset.form.columns_exclude 

196 

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

198 columns = forms.MultipleChoiceField( 

199 required=False, 

200 choices=self._get_columns_choices(columns_exclude), 

201 initial=self._get_columns_initial(columns_exclude), 

202 ) 

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

204 

205 return filterset 

206 

207 def get_table_pagination(self, table): 

208 """ 

209 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

211 """ 

212 self.paginate_by = getattr(table, "paginate_by", None) 

213 self.table_pagination = getattr(table, "table_pagination", None) 

214 return super().get_table_pagination(table) 

215 

216 

217class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

218 """ 

219 Detail view for a generic model. 

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

221 """ 

222 

223 permission_action_required = "view" 

224 

225 

226class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): 

227 """ 

228 Create view for a generic model. 

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

230 The form class is overridden by the first match from 

231 the `first_member_match` helper. 

232 """ 

233 

234 template_name = "generic/generic_form.html" 

235 permission_action_required = "add" 

236 

237 def get_form_class(self): 

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

239 form_class = first_member_match(form_modules, GenericModelForm) 

240 return modelform_factory(self.model, form_class) 

241 

242 def get_success_url(self): 

243 return self.object.get_create_success_url() 

244 

245 

246class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

247 """ 

248 Delete view for a generic model. 

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

250 """ 

251 

252 permission_action_required = "delete" 

253 

254 def get_success_url(self): 

255 return reverse( 

256 "apis_core:generic:list", 

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

258 ) 

259 

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

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

262 return ( 

263 reverse_lazy( 

264 "apis_core:generic:list", 

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

266 ), 

267 ) 

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

269 

270 

271class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): 

272 """ 

273 Update view for a generic model. 

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

275 The form class is overridden by the first match from 

276 the `first_member_match` helper. 

277 """ 

278 

279 permission_action_required = "change" 

280 

281 def get_form_class(self): 

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

283 form_class = first_member_match(form_modules, GenericModelForm) 

284 return modelform_factory(self.model, form_class) 

285 

286 def get_success_url(self): 

287 return self.object.get_update_success_url() 

288 

289 

290class Autocomplete( 

291 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

292): 

293 """ 

294 Autocomplete view for a generic model. 

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

296 The queryset is overridden by the first match from 

297 the `first_member_match` helper. 

298 """ 

299 

300 permission_action_required = "view" 

301 template_name_suffix = "_autocomplete_result" 

302 

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

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

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

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

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

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

309 try: 

310 template = select_template(self.get_template_names()) 

311 self.template = template.template.name 

312 except TemplateDoesNotExist: 

313 self.template = None 

314 

315 def get_queryset(self): 

316 queryset_methods = module_paths( 

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

318 ) 

319 queryset = first_member_match(queryset_methods) 

320 if queryset: 

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

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

323 

324 def get_results(self, context): 

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

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

327 queryset_methods = module_paths( 

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

329 ) 

330 ExternalAutocomplete = first_member_match(queryset_methods) 

331 if ExternalAutocomplete: 

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

333 return results 

334 

335 def create_object(self, value): 

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

337 

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

339 try: 

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

341 except Exception as e: 

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

343 

344 

345class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

346 template_name = "generic/generic_import_form.html" 

347 template_name_suffix = "_import" 

348 permission_action_required = "add" 

349 

350 def get_form_class(self): 

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

352 form_class = first_member_match(form_modules, GenericImportForm) 

353 return modelform_factory(self.model, form_class) 

354 

355 def form_valid(self, form): 

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

357 return super().form_valid(form) 

358 

359 def get_success_url(self): 

360 return self.object.get_absolute_url() 

361 

362 

363class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

364 """ 

365 This view provides a simple form that allows to select other entities (also from 

366 external sources, if set up) and on form submit redirects to the Enrich view. 

367 """ 

368 

369 template_name_suffix = "_selectmergeorenrich" 

370 permission_action_required = "create" 

371 form_class = GenericSelectMergeOrEnrichForm 

372 

373 def get_object(self, *args, **kwargs): 

374 return get_object_or_404(self.model, pk=self.kwargs.get("pk")) 

375 

376 def get_context_data(self, *args, **kwargs): 

377 context = super().get_context_data(*args, **kwargs) 

378 context["object"] = self.get_object() 

379 return context 

380 

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

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

383 kwargs["instance"] = self.get_object() 

384 return kwargs 

385 

386 

387class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

388 """ 

389 Generic merge view. 

390 """ 

391 

392 permission_action_required = "change" 

393 form_class = GenericMergeWithForm 

394 template_name = "generic/generic_merge.html" 

395 

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

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

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

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

400 

401 def get_context_data(self, **kwargs): 

402 """ 

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

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

405 a table with diffs 

406 """ 

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

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

409 ctx["changes"] = [] 

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

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

412 ctx["changes"].append( 

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

414 ) 

415 ctx["object"] = self.object 

416 ctx["other"] = self.other 

417 return ctx 

418 

419 def form_valid(self, form): 

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

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

422 return super().form_valid(form) 

423 

424 def get_success_url(self): 

425 return self.object.get_absolute_url() 

426 

427 

428class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

429 """ 

430 Enrich an entity with data from an external source 

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

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

433 """ 

434 

435 permission_action_required = "change" 

436 template_name = "generic/generic_enrich.html" 

437 form_class = GenericEnrichForm 

438 importer_class = None 

439 

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

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

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

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

444 if not self.uri: 

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

446 self.importer_class = get_importer_for_model(self.model) 

447 

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

449 if self.uri.isdigit(): 

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

451 try: 

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

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

454 messages.info( 

455 self.request, 

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

457 ) 

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

459 except Uri.DoesNotExist: 

460 pass 

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

462 

463 def get_context_data(self, **kwargs): 

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

465 ctx["object"] = self.object 

466 ctx["uri"] = self.uri 

467 return ctx 

468 

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

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

471 kwargs["instance"] = self.object 

472 try: 

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

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

475 except ImproperlyConfigured as e: 

476 messages.error(self.request, e) 

477 return kwargs 

478 

479 def form_valid(self, form): 

480 """ 

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

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

483 select which fields to update). 

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

485 fields values on the model instance. 

486 """ 

487 update_fields = [ 

488 key.removeprefix("update_") 

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

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

491 ] 

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

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

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

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

496 if created: 

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

498 return super().form_valid(form) 

499 

500 def get_success_url(self): 

501 return self.object.get_absolute_url()