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

263 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-02-19 16:54 +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.contrib.contenttypes.models import ContentType 

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.export.views import ExportMixin 

25from django_tables2.tables import table_factory 

26 

27from apis_core.apis_metainfo.models import Uri 

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 GenericMergeWithForm, 

35 GenericModelForm, 

36 GenericSelectMergeOrEnrichForm, 

37) 

38from .helpers import ( 

39 first_member_match, 

40 generate_search_filter, 

41 module_paths, 

42 permission_fullname, 

43 template_names_via_mro, 

44) 

45from .tables import GenericTable 

46 

47 

48class Overview(TemplateView): 

49 template_name = "generic/overview.html" 

50 

51 

52class GenericModelMixin: 

53 """ 

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

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

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

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

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

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

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

61 """ 

62 

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

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

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

66 self.model = contenttype.model_class() 

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

68 

69 def get_template_names(self): 

70 template_names = [] 

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

72 # Some parent classes come with custom template_names, 

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

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

75 # gracefully 

76 try: 

77 template_names = super().get_template_names() 

78 except ImproperlyConfigured: 

79 pass 

80 suffix = ".html" 

81 if hasattr(self, "template_name_suffix"): 

82 suffix = self.template_name_suffix + ".html" 

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

84 f"generic/generic{suffix}" 

85 ] 

86 template_names += filter( 

87 lambda template: template not in template_names, additional_templates 

88 ) 

89 return template_names 

90 

91 def get_permission_required(self): 

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

93 settings, "APIS_ANON_VIEWS_ALLOWED", False 

94 ): 

95 return [] 

96 if hasattr(self, "permission_action_required"): 

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

98 return [] 

99 

100 

101class List( 

102 GenericModelMixin, 

103 PermissionRequiredMixin, 

104 ExportMixin, 

105 SingleTableMixin, 

106 FilterView, 

107): 

108 """ 

109 List view for a generic model. 

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

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

112 The table class is overridden by the first match from 

113 the `first_member_match` helper. 

114 The filterset class is overridden by the first match from 

115 the `first_member_match` helper. 

116 The queryset is overridden by the first match from 

117 the `first_member_match` helper. 

118 """ 

119 

120 template_name_suffix = "_list" 

121 permission_action_required = "view" 

122 

123 def get_table_class(self): 

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

125 table_class = first_member_match(table_modules, GenericTable) 

126 return table_factory(self.model, table_class) 

127 

128 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"]) 

129 

130 def get_export_filename(self, extension): 

131 table_class = self.get_table_class() 

132 if hasattr(table_class, "export_filename"): 

133 return f"{table_class.export_filename}.{extension}" 

134 

135 return super().get_export_filename(extension) 

136 

137 def get_table_kwargs(self): 

138 kwargs = super().get_table_kwargs() 

139 

140 # we look at the selected columns and exclude 

141 # all modelfields that are not part of that list 

142 selected_columns = self.request.GET.getlist( 

143 "columns", 

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

145 ) 

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

147 kwargs["exclude"] = [ 

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

149 ] 

150 

151 # now we look at the selected columns and 

152 # add all modelfields and annotated fields that 

153 # are part of the selected columns to the extra_columns 

154 annotationfields = list() 

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

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

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

158 setattr(fake_field, "name", key) 

159 annotationfields.append(fake_field) 

160 extra_fields = list( 

161 filter( 

162 lambda x: x.name in selected_columns, 

163 modelfields + tuple(annotationfields), 

164 ) 

165 ) 

166 kwargs["extra_columns"] = [ 

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

168 for field in extra_fields 

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

170 ] 

171 

172 return kwargs 

173 

174 def get_filterset_class(self): 

175 filterset_modules = module_paths( 

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

177 ) 

178 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

179 return filterset_factory(self.model, filterset_class) 

180 

181 def _get_columns_choices(self, columns_exclude): 

182 # we start with the model fields 

183 choices = [ 

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

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

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

187 ] 

188 # we add any annotated fields to that 

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

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

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

192 return choices 

193 

194 def _get_columns_initial(self, columns_exclude): 

195 return [ 

196 field 

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

198 if field not in columns_exclude 

199 ] 

200 

201 def get_filterset(self, filterset_class): 

202 """ 

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

204 `columns` selector into the form 

205 """ 

206 filterset = super().get_filterset(filterset_class) 

207 columns_exclude = filterset.form.columns_exclude 

208 

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

210 columns = forms.MultipleChoiceField( 

211 required=False, 

212 choices=self._get_columns_choices(columns_exclude), 

213 initial=self._get_columns_initial(columns_exclude), 

214 ) 

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

216 

217 return filterset 

218 

219 def get_table_pagination(self, table): 

220 """ 

221 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

223 """ 

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

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

226 return super().get_table_pagination(table) 

227 

228 

229class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

230 """ 

231 Detail view for a generic model. 

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

233 """ 

234 

235 permission_action_required = "view" 

236 

237 

238class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): 

239 """ 

240 Create view for a generic model. 

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

242 The form class is overridden by the first match from 

243 the `first_member_match` helper. 

244 """ 

245 

246 template_name = "generic/generic_form.html" 

247 permission_action_required = "add" 

248 

249 def get_form_class(self): 

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

251 form_class = first_member_match(form_modules, GenericModelForm) 

252 return modelform_factory(self.model, form_class) 

253 

254 def get_success_url(self): 

255 return self.object.get_create_success_url() 

256 

257 

258class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

259 """ 

260 Delete view for a generic model. 

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

262 """ 

263 

264 permission_action_required = "delete" 

265 

266 def get_success_url(self): 

267 return reverse( 

268 "apis_core:generic:list", 

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

270 ) 

271 

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

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

274 return ( 

275 reverse_lazy( 

276 "apis_core:generic:list", 

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

278 ), 

279 ) 

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

281 

282 

283class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): 

284 """ 

285 Update view for a generic model. 

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

287 The form class is overridden by the first match from 

288 the `first_member_match` helper. 

289 """ 

290 

291 permission_action_required = "change" 

292 

293 def get_form_class(self): 

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

295 form_class = first_member_match(form_modules, GenericModelForm) 

296 return modelform_factory(self.model, form_class) 

297 

298 def get_success_url(self): 

299 return self.object.get_update_success_url() 

300 

301 

302class Autocomplete( 

303 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

304): 

305 """ 

306 Autocomplete view for a generic model. 

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

308 The queryset is overridden by the first match from 

309 the `first_member_match` helper. 

310 """ 

311 

312 permission_action_required = "view" 

313 template_name_suffix = "_autocomplete_result" 

314 

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

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

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

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

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

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

321 try: 

322 template = select_template(self.get_template_names()) 

323 self.template = template.template.name 

324 except TemplateDoesNotExist: 

325 self.template = None 

326 

327 def get_queryset(self): 

328 queryset_methods = module_paths( 

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

330 ) 

331 queryset = first_member_match(queryset_methods) 

332 if queryset: 

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

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

335 

336 def get_results(self, context): 

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

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

339 queryset_methods = module_paths( 

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

341 ) 

342 ExternalAutocomplete = first_member_match(queryset_methods) 

343 if ExternalAutocomplete: 

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

345 return results 

346 

347 def create_object(self, value): 

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

349 

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

351 try: 

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

353 except Exception as e: 

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

355 

356 

357class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

358 template_name = "generic/generic_import_form.html" 

359 template_name_suffix = "_import" 

360 permission_action_required = "add" 

361 

362 def get_form_class(self): 

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

364 form_class = first_member_match(form_modules, GenericImportForm) 

365 return modelform_factory(self.model, form_class) 

366 

367 def form_valid(self, form): 

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

369 return super().form_valid(form) 

370 

371 def get_success_url(self): 

372 return self.object.get_absolute_url() 

373 

374 

375class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

376 """ 

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

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

379 """ 

380 

381 template_name_suffix = "_selectmergeorenrich" 

382 permission_action_required = "create" 

383 form_class = GenericSelectMergeOrEnrichForm 

384 

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

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

387 

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

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

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

391 return context 

392 

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

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

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

396 return kwargs 

397 

398 

399class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

400 """ 

401 Generic merge view. 

402 """ 

403 

404 permission_action_required = "change" 

405 form_class = GenericMergeWithForm 

406 template_name = "generic/generic_merge.html" 

407 

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

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

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

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

412 

413 def get_context_data(self, **kwargs): 

414 """ 

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

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

417 a table with diffs 

418 """ 

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

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

421 ctx["changes"] = [] 

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

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

424 ctx["changes"].append( 

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

426 ) 

427 ctx["object"] = self.object 

428 ctx["other"] = self.other 

429 return ctx 

430 

431 def form_valid(self, form): 

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

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

434 return super().form_valid(form) 

435 

436 def get_success_url(self): 

437 return self.object.get_absolute_url() 

438 

439 

440class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

441 """ 

442 Enrich an entity with data from an external source 

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

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

445 """ 

446 

447 permission_action_required = "change" 

448 template_name = "generic/generic_enrich.html" 

449 form_class = GenericEnrichForm 

450 importer_class = None 

451 

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

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

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

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

456 if not self.uri: 

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

458 self.importer_class = get_importer_for_model(self.model) 

459 

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

461 if self.uri.isdigit(): 

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

463 try: 

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

465 if uriobj.object_id != self.object.id: 

466 messages.info( 

467 self.request, 

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

469 ) 

470 return redirect(self.object.get_merge_url(uriobj.object_id)) 

471 except Uri.DoesNotExist: 

472 pass 

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

474 

475 def get_context_data(self, **kwargs): 

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

477 ctx["object"] = self.object 

478 ctx["uri"] = self.uri 

479 return ctx 

480 

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

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

483 kwargs["instance"] = self.object 

484 try: 

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

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

487 except ImproperlyConfigured as e: 

488 messages.error(self.request, e) 

489 return kwargs 

490 

491 def form_valid(self, form): 

492 """ 

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

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

495 select which fields to update). 

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

497 fields values on the model instance. 

498 """ 

499 update_fields = [ 

500 key.removeprefix("update_") 

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

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

503 ] 

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

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

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

507 content_type = ContentType.objects.get_for_model(self.model) 

508 uri, created = Uri.objects.get_or_create( 

509 uri=self.uri, 

510 content_type=content_type, 

511 object_id=self.object.id, 

512 ) 

513 if created: 

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

515 return super().form_valid(form) 

516 

517 def get_success_url(self): 

518 return self.object.get_absolute_url()