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

290 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-06-25 10:00 +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, ValidationError 

11from django.core.validators import URLValidator 

12from django.db.models.fields.related import ManyToManyRel 

13from django.forms import modelform_factory 

14from django.forms.utils import pretty_name 

15from django.shortcuts import get_object_or_404, redirect 

16from django.template.exceptions import TemplateDoesNotExist 

17from django.template.loader import select_template 

18from django.urls import reverse, reverse_lazy 

19from django.utils.html import format_html 

20from django.views import View 

21from django.views.generic import DetailView 

22from django.views.generic.base import TemplateView 

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

24from django_filters.filterset import filterset_factory 

25from django_filters.views import FilterView 

26from django_tables2 import SingleTableMixin 

27from django_tables2.columns import library 

28from django_tables2.export.views import ExportMixin 

29from django_tables2.tables import table_factory 

30 

31from apis_core.apis_metainfo.models import Uri 

32from apis_core.apis_metainfo.utils import create_object_from_uri 

33from apis_core.utils.helpers import get_importer_for_model 

34 

35from .filtersets import GenericFilterSet 

36from .forms import ( 

37 GenericEnrichForm, 

38 GenericImportForm, 

39 GenericMergeWithForm, 

40 GenericModelForm, 

41 GenericSelectMergeOrEnrichForm, 

42) 

43from .helpers import ( 

44 first_member_match, 

45 generate_search_filter, 

46 module_paths, 

47 permission_fullname, 

48 template_names_via_mro, 

49) 

50from .tables import GenericTable 

51 

52 

53class Overview(TemplateView): 

54 template_name = "generic/overview.html" 

55 

56 

57class GenericModelMixin: 

58 """ 

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

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

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

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

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

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

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

66 """ 

67 

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

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

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

71 self.model = contenttype.model_class() 

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

73 

74 def get_template_names(self): 

75 template_names = [] 

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

77 # Some parent classes come with custom template_names, 

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

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

80 # gracefully 

81 try: 

82 template_names = super().get_template_names() 

83 except ImproperlyConfigured: 

84 pass 

85 suffix = ".html" 

86 if hasattr(self, "template_name_suffix"): 

87 suffix = self.template_name_suffix + ".html" 

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

89 f"generic/generic{suffix}" 

90 ] 

91 template_names += filter( 

92 lambda template: template not in template_names, additional_templates 

93 ) 

94 return template_names 

95 

96 def get_permission_required(self): 

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

98 settings, "APIS_ANON_VIEWS_ALLOWED", False 

99 ): 

100 return [] 

101 if hasattr(self, "permission_action_required"): 

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

103 return [] 

104 

105 

106class List( 

107 GenericModelMixin, 

108 PermissionRequiredMixin, 

109 ExportMixin, 

110 SingleTableMixin, 

111 FilterView, 

112): 

113 """ 

114 List view for a generic model. 

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

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

117 The table class is overridden by the first match from 

118 the `first_member_match` helper. 

119 The filterset class is overridden by the first match from 

120 the `first_member_match` helper. 

121 The queryset is overridden by the first match from 

122 the `first_member_match` helper. 

123 """ 

124 

125 template_name_suffix = "_list" 

126 permission_action_required = "view" 

127 

128 def get_table_class(self): 

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

130 table_class = first_member_match(table_modules, GenericTable) 

131 return table_factory(self.model, table_class) 

132 

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

134 

135 def get_export_filename(self, extension): 

136 table_class = self.get_table_class() 

137 if hasattr(table_class, "export_filename"): 

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

139 

140 return super().get_export_filename(extension) 

141 

142 def get_table_kwargs(self): 

143 kwargs = super().get_table_kwargs() 

144 

145 # we look at the selected columns and exclude 

146 # all modelfields that are not part of that list 

147 form = self.get_filterset(self.get_filterset_class()).form 

148 initial = form.fields["columns"].initial if "columns" in form.fields else [] 

149 selected_columns = self.request.GET.getlist("columns", initial) 

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

151 kwargs["exclude"] = [ 

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

153 ] 

154 

155 # now we look at the selected columns and 

156 # add all modelfields and annotated fields that 

157 # are part of the selected columns to the extra_columns 

158 annotationfields = list() 

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

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

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

162 setattr(fake_field, "name", key) 

163 annotationfields.append(fake_field) 

164 extra_fields = list( 

165 filter( 

166 lambda x: x.name in selected_columns, 

167 modelfields + tuple(annotationfields), 

168 ) 

169 ) 

170 kwargs["extra_columns"] = [ 

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

172 for field in extra_fields 

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

174 ] 

175 

176 return kwargs 

177 

178 def get_filterset_class(self): 

179 filterset_modules = module_paths( 

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

181 ) 

182 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

183 return filterset_factory(self.model, filterset_class) 

184 

185 def _get_columns_choices(self, columns_exclude): 

186 # we start with the model fields 

187 choices = [ 

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

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

190 if not getattr(field, "auto_created", False) 

191 and not isinstance(field, ManyToManyRel) 

192 ] 

193 # we add any annotated fields to that 

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

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

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

197 return choices 

198 

199 def _get_columns_initial(self, columns_exclude): 

200 return [ 

201 field 

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

203 if field not in columns_exclude 

204 ] 

205 

206 def get_filterset(self, filterset_class): 

207 """ 

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

209 `columns` selector into the form 

210 """ 

211 filterset = super().get_filterset(filterset_class) 

212 columns_exclude = filterset.form.columns_exclude 

213 

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

215 if choices := self._get_columns_choices(columns_exclude): 

216 columns = forms.MultipleChoiceField( 

217 required=False, 

218 choices=choices, 

219 initial=self._get_columns_initial(columns_exclude), 

220 ) 

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

222 

223 return filterset 

224 

225 def get_queryset(self): 

226 queryset_methods = module_paths( 

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

228 ) 

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

230 return queryset(self.model.objects.all()) 

231 

232 def get_table_pagination(self, table): 

233 """ 

234 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

236 """ 

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

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

239 return super().get_table_pagination(table) 

240 

241 

242class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

243 """ 

244 Detail view for a generic model. 

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

246 """ 

247 

248 permission_action_required = "view" 

249 

250 

251class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): 

252 """ 

253 Create view for a generic model. 

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

255 The form class is overridden by the first match from 

256 the `first_member_match` helper. 

257 """ 

258 

259 template_name = "generic/generic_form.html" 

260 permission_action_required = "add" 

261 

262 def get_form_class(self): 

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

264 form_class = first_member_match(form_modules, GenericModelForm) 

265 return modelform_factory(self.model, form_class) 

266 

267 def get_success_url(self): 

268 return self.object.get_create_success_url() 

269 

270 

271class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

272 """ 

273 Delete view for a generic model. 

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

275 """ 

276 

277 permission_action_required = "delete" 

278 

279 def get_success_url(self): 

280 return reverse( 

281 "apis_core:generic:list", 

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

283 ) 

284 

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

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

287 return ( 

288 reverse_lazy( 

289 "apis_core:generic:list", 

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

291 ), 

292 ) 

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

294 

295 

296class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): 

297 """ 

298 Update view for a generic model. 

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

300 The form class is overridden by the first match from 

301 the `first_member_match` helper. 

302 """ 

303 

304 permission_action_required = "change" 

305 

306 def get_form_class(self): 

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

308 form_class = first_member_match(form_modules, GenericModelForm) 

309 return modelform_factory(self.model, form_class) 

310 

311 def get_success_url(self): 

312 return self.object.get_update_success_url() 

313 

314 

315class Duplicate(GenericModelMixin, PermissionRequiredMixin, View): 

316 permission_action_required = "add" 

317 

318 def get(self, request, *args, **kwargs): 

319 source_obj = get_object_or_404(self.model, pk=kwargs["pk"]) 

320 newobj = source_obj.duplicate() 

321 

322 messages.success( 

323 request, 

324 format_html( 

325 "<a href={}>{}</a> was successfully duplicated to the current object:", 

326 source_obj.get_absolute_url(), 

327 source_obj, 

328 ), 

329 ) 

330 return redirect(newobj.get_edit_url()) 

331 

332 

333class Autocomplete( 

334 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

335): 

336 """ 

337 Autocomplete view for a generic model. 

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

339 The queryset is overridden by the first match from 

340 the `first_member_match` helper. 

341 """ 

342 

343 permission_action_required = "view" 

344 template_name_suffix = "_autocomplete_result" 

345 

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

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

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

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

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

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

352 try: 

353 template = select_template(self.get_template_names()) 

354 self.template = template.template.name 

355 except TemplateDoesNotExist: 

356 self.template = None 

357 

358 def get_queryset(self): 

359 queryset_methods = module_paths( 

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

361 ) 

362 queryset = first_member_match(queryset_methods) 

363 if queryset: 

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

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

366 

367 def get_results(self, context): 

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

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

370 queryset_methods = module_paths( 

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

372 ) 

373 ExternalAutocomplete = first_member_match(queryset_methods) 

374 if ExternalAutocomplete: 

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

376 return results 

377 

378 def create_object(self, value): 

379 """ 

380 We try multiple approaches to create a model instance from a value: 

381 * we first test if the value is an URL and if so we expect it to be 

382 something that can be imported using one of the configured importers 

383 and so we pass the value to the import logic. 

384 * if the value is not a string, we try to pass it to the `create_from_string` 

385 method of the model, if that does exist. Its the models responsibility to 

386 implement this method and the method should somehow know how to create 

387 model instance from the value... 

388 * finally we pass the value to the `create_object` method from the DAL 

389 view, which tries to pass it to `get_or_create` which likely also fails, 

390 but this is expected and we raise a more useful exception. 

391 """ 

392 try: 

393 URLValidator()(value) 

394 return create_object_from_uri( 

395 value, self.queryset.model, raise_on_fail=True 

396 ) 

397 except ValidationError: 

398 pass 

399 try: 

400 return self.queryset.model.create_from_string(value) 

401 except AttributeError: 

402 raise ImproperlyConfigured( 

403 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string' 

404 ) 

405 

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

407 try: 

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

409 except Exception as e: 

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

411 

412 

413class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

414 template_name = "generic/generic_import_form.html" 

415 template_name_suffix = "_import" 

416 permission_action_required = "add" 

417 

418 def get_form_class(self): 

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

420 form_class = first_member_match(form_modules, GenericImportForm) 

421 return modelform_factory(self.model, form_class) 

422 

423 def form_valid(self, form): 

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

425 return super().form_valid(form) 

426 

427 def get_success_url(self): 

428 return self.object.get_absolute_url() 

429 

430 

431class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

432 """ 

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

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

435 """ 

436 

437 template_name_suffix = "_selectmergeorenrich" 

438 permission_action_required = "add" 

439 form_class = GenericSelectMergeOrEnrichForm 

440 

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

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

443 

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

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

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

447 return context 

448 

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

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

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

452 return kwargs 

453 

454 

455class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

456 """ 

457 Generic merge view. 

458 """ 

459 

460 permission_action_required = "change" 

461 form_class = GenericMergeWithForm 

462 template_name = "generic/generic_merge.html" 

463 

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

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

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

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

468 

469 def get_context_data(self, **kwargs): 

470 """ 

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

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

473 a table with diffs 

474 """ 

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

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

477 ctx["changes"] = [] 

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

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

480 ctx["changes"].append( 

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

482 ) 

483 ctx["object"] = self.object 

484 ctx["other"] = self.other 

485 return ctx 

486 

487 def form_valid(self, form): 

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

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

490 return super().form_valid(form) 

491 

492 def get_success_url(self): 

493 return self.object.get_absolute_url() 

494 

495 

496class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

497 """ 

498 Enrich an entity with data from an external source 

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

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

501 """ 

502 

503 permission_action_required = "change" 

504 template_name = "generic/generic_enrich.html" 

505 form_class = GenericEnrichForm 

506 importer_class = None 

507 

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

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

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

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

512 if not self.uri: 

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

514 self.importer_class = get_importer_for_model(self.model) 

515 

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

517 if self.uri.isdigit(): 

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

519 try: 

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

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

522 messages.info( 

523 self.request, 

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

525 ) 

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

527 except Uri.DoesNotExist: 

528 pass 

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

530 

531 def get_context_data(self, **kwargs): 

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

533 ctx["object"] = self.object 

534 ctx["uri"] = self.uri 

535 return ctx 

536 

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

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

539 kwargs["instance"] = self.object 

540 try: 

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

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

543 except ImproperlyConfigured as e: 

544 messages.error(self.request, e) 

545 return kwargs 

546 

547 def form_valid(self, form): 

548 """ 

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

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

551 select which fields to update). 

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

553 fields values on the model instance. 

554 """ 

555 update_fields = [ 

556 key.removeprefix("update_") 

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

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

559 ] 

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

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

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

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

564 uri, created = Uri.objects.get_or_create( 

565 uri=importer.get_uri, 

566 content_type=content_type, 

567 object_id=self.object.id, 

568 ) 

569 if created: 

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

571 return super().form_valid(form) 

572 

573 def get_success_url(self): 

574 return self.object.get_absolute_url()