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

323 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-10-10 13:36 +0000

1from collections import namedtuple 

2from copy import copy 

3 

4from crispy_forms.layout import Field 

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.contrib.contenttypes.models import ContentType 

11from django.contrib.messages.views import SuccessMessageMixin 

12from django.core.exceptions import ImproperlyConfigured, ValidationError 

13from django.core.validators import URLValidator 

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

15from django.forms import modelform_factory 

16from django.forms.utils import pretty_name 

17from django.http import QueryDict 

18from django.shortcuts import get_object_or_404, redirect 

19from django.template.exceptions import TemplateDoesNotExist 

20from django.template.loader import select_template 

21from django.urls import reverse 

22from django.utils.text import capfirst 

23from django.views import View 

24from django.views.generic import DetailView 

25from django.views.generic.base import TemplateView 

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

27from django_filters.filterset import filterset_factory 

28from django_filters.views import FilterView 

29from django_tables2 import SingleTableMixin 

30from django_tables2.columns import library 

31from django_tables2.export.views import ExportMixin 

32from django_tables2.tables import table_factory 

33 

34from apis_core.uris.models import Uri 

35 

36from .filtersets import GenericFilterSet 

37from .forms import ( 

38 GenericEnrichForm, 

39 GenericImportForm, 

40 GenericMergeWithForm, 

41 GenericModelForm, 

42 GenericSelectMergeOrEnrichForm, 

43) 

44from .helpers import ( 

45 first_member_match, 

46 generate_search_filter, 

47 module_paths, 

48 permission_fullname, 

49 template_names_via_mro, 

50) 

51from .tables import GenericTable 

52 

53 

54class Overview(TemplateView): 

55 template_name = "generic/overview.html" 

56 

57 

58class GenericModelMixin: 

59 """ 

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

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

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

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

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

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

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

67 """ 

68 

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

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

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

72 self.model = contenttype.model_class() 

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

74 

75 def get_template_names(self): 

76 template_names = [] 

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

78 # Some parent classes come with custom template_names, 

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

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

81 # gracefully 

82 try: 

83 template_names = super().get_template_names() 

84 except ImproperlyConfigured: 

85 pass 

86 suffix = ".html" 

87 if hasattr(self, "template_name_suffix"): 

88 suffix = self.template_name_suffix + ".html" 

89 additional_templates = template_names_via_mro(self.model, suffix=suffix) 

90 template_names += filter( 

91 lambda template: template not in template_names, additional_templates 

92 ) 

93 return template_names 

94 

95 def get_permission_required(self): 

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

97 settings, "APIS_ANON_VIEWS_ALLOWED", False 

98 ): 

99 return [] 

100 if hasattr(self, "permission_action_required"): 

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

102 return [] 

103 

104 

105class List( 

106 GenericModelMixin, 

107 PermissionRequiredMixin, 

108 ExportMixin, 

109 SingleTableMixin, 

110 FilterView, 

111): 

112 """ 

113 List view for a generic model. 

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

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

116 The table class is overridden by the first match from 

117 the `first_member_match` helper. 

118 The filterset class is overridden by the first match from 

119 the `first_member_match` helper. 

120 The queryset is overridden by the first match from 

121 the `first_member_match` helper. 

122 """ 

123 

124 template_name_suffix = "_list" 

125 permission_action_required = "view" 

126 

127 def get_table_class(self): 

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

129 table_class = first_member_match(table_modules, GenericTable) 

130 return table_factory(self.model, table_class) 

131 

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

133 

134 def get_export_filename(self, extension): 

135 table_class = self.get_table_class() 

136 if hasattr(table_class, "export_filename"): 

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

138 

139 return super().get_export_filename(extension) 

140 

141 def get_table_kwargs(self): 

142 kwargs = super().get_table_kwargs() 

143 

144 selected_columns = self.request.GET.getlist("columns", []) 

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

146 # if the form was submitted, we look at the selected 

147 # columns and exclude all columns that are not part of that list 

148 if self.request.GET and "columns" in self.request.GET: 

149 columns_exclude = self.get_filterset_class().Meta.form.columns_exclude 

150 other_columns = [ 

151 name for (name, field) in self._get_columns_choices(columns_exclude) 

152 ] 

153 kwargs["exclude"] = [ 

154 field for field in other_columns if field not in selected_columns 

155 ] 

156 

157 # now we look at the selected columns and 

158 # add all modelfields and annotated fields that 

159 # are part of the selected columns to the extra_columns 

160 annotationfields = list() 

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

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

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

164 setattr(fake_field, "name", key) 

165 annotationfields.append(fake_field) 

166 extra_fields = list( 

167 filter( 

168 lambda x: x.name in selected_columns, 

169 modelfields + tuple(annotationfields), 

170 ) 

171 ) 

172 kwargs["extra_columns"] = [ 

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

174 for field in extra_fields 

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

176 ] 

177 

178 return kwargs 

179 

180 def get_filterset_class(self): 

181 filterset_modules = module_paths( 

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

183 ) 

184 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

185 return filterset_factory(self.model, filterset_class) 

186 

187 def _get_columns_choices(self, columns_exclude): 

188 # we start with the model fields 

189 choices = [ 

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

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

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

193 and not isinstance(field, ManyToManyRel) 

194 ] 

195 # we add any annotated fields to that 

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

197 # lets also add the custom table fields 

198 choices += [ 

199 (key.name, capfirst(str(key) or key.name or "Nameless column")) 

200 for key in self.get_table().columns 

201 ] 

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

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

204 return choices 

205 

206 def get_filterset_kwargs(self, filterset_class): 

207 columns_exclude = filterset_class.Meta.form.columns_exclude 

208 table_columns = self.get_table().columns 

209 initial_columns = [ 

210 col.name for col in table_columns if col.name not in columns_exclude 

211 ] 

212 kwargs = super().get_filterset_kwargs(filterset_class) 

213 data = (kwargs.get("data", QueryDict()) or QueryDict()).copy() 

214 if not data.get("columns"): 

215 data.setlist("columns", initial_columns) 

216 kwargs["data"] = data 

217 return kwargs 

218 

219 def get_filterset(self, filterset_class): 

220 """ 

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

222 `columns` selector into the form 

223 """ 

224 filterset = super().get_filterset(filterset_class) 

225 columns_exclude = filterset.form.columns_exclude 

226 

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

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

229 columns = forms.MultipleChoiceField( 

230 required=False, 

231 choices=choices, 

232 ) 

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

234 # rebuild the layout, now that the columns field was added 

235 filterset.form.helper.layout = filterset.form.helper.build_default_layout( 

236 filterset.form 

237 ) 

238 # If the filterset form contains form data 

239 # we add a CSS class to the element wrapping 

240 # that field in HTML. This CSS class can be 

241 # used to emphasize the fields that are used. 

242 # To be able to compare the fields with the form 

243 # data, we create a temporary mapping between 

244 # widget_names and fields 

245 fields = {} 

246 for name, field in filterset.form.fields.items(): 

247 fields[name] = name 

248 if hasattr(field.widget, "widgets_names"): 

249 for widget_name in field.widget.widgets_names: 

250 fields[name + widget_name] = name 

251 if data := filterset.form.data: 

252 for param in [param for param, value in data.items() if value]: 

253 if fieldname := fields.get(param, None): 

254 filterset.form.helper[fieldname].wrap( 

255 Field, wrapper_class="filter-input-selected" 

256 ) 

257 

258 return filterset 

259 

260 def get_queryset(self): 

261 queryset_methods = module_paths( 

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

263 ) 

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

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

266 

267 def get_table_pagination(self, table): 

268 """ 

269 Override `get_table_pagination` from the tables2 TableMixinBase, 

270 so we can set the table_pagination value as attribute of the table. 

271 """ 

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

273 return super().get_table_pagination(table) 

274 

275 

276class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

277 """ 

278 Detail view for a generic model. 

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

280 """ 

281 

282 permission_action_required = "view" 

283 

284 

285class Create( 

286 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView 

287): 

288 """ 

289 Create view for a generic model. 

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

291 The form class is overridden by the first match from 

292 the `first_member_match` helper. 

293 """ 

294 

295 template_name_suffix = "_create" 

296 permission_action_required = "add" 

297 

298 def get_form_class(self): 

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

300 form_class = first_member_match(form_modules, GenericModelForm) 

301 return modelform_factory(self.model, form_class) 

302 

303 def get_success_message(self, cleaned_data): 

304 message_templates = template_names_via_mro( 

305 self.model, suffix="_create_success_message.html" 

306 ) 

307 template = select_template(message_templates) 

308 return template.render({"object": self.object}) 

309 

310 def get_success_url(self): 

311 return self.object.get_create_success_url() 

312 

313 

314class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

315 """ 

316 Delete view for a generic model. 

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

318 """ 

319 

320 permission_action_required = "delete" 

321 

322 def get_success_url(self): 

323 if redirect := self.request.GET.get("redirect"): 

324 return redirect 

325 return reverse( 

326 "apis_core:generic:list", 

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

328 ) 

329 

330 

331class Update( 

332 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView 

333): 

334 """ 

335 Update view for a generic model. 

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

337 The form class is overridden by the first match from 

338 the `first_member_match` helper. 

339 """ 

340 

341 permission_action_required = "change" 

342 

343 def get_form_class(self): 

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

345 form_class = first_member_match(form_modules, GenericModelForm) 

346 return modelform_factory(self.model, form_class) 

347 

348 def get_success_message(self, cleaned_data): 

349 message_templates = template_names_via_mro( 

350 self.model, suffix="_update_success_message.html" 

351 ) 

352 template = select_template(message_templates) 

353 return template.render({"object": self.object}) 

354 

355 def get_success_url(self): 

356 return self.object.get_update_success_url() 

357 

358 

359class Duplicate(GenericModelMixin, PermissionRequiredMixin, View): 

360 permission_action_required = "add" 

361 

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

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

364 newobj = source_obj.duplicate() 

365 

366 message_templates = template_names_via_mro( 

367 self.model, suffix="_duplicate_success_message.html" 

368 ) 

369 template = select_template(message_templates) 

370 messages.success(request, template.render({"object": source_obj})) 

371 return redirect(newobj.get_edit_url()) 

372 

373 

374class Autocomplete( 

375 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

376): 

377 """ 

378 Autocomplete view for a generic model. 

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

380 The queryset is overridden by the first match from 

381 the `first_member_match` helper. 

382 """ 

383 

384 permission_action_required = "view" 

385 template_name_suffix = "_autocomplete_result" 

386 

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

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

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

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

391 # `create_field` is, because we override create_object anyway. 

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

393 try: 

394 template = select_template(self.get_template_names()) 

395 self.template = template.template.name 

396 except TemplateDoesNotExist: 

397 self.template = None 

398 

399 def get_queryset(self): 

400 queryset_methods = module_paths( 

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

402 ) 

403 queryset = first_member_match(queryset_methods) 

404 if queryset: 

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

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

407 

408 def get_results(self, context): 

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

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

411 queryset_methods = module_paths( 

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

413 ) 

414 ExternalAutocomplete = first_member_match(queryset_methods) 

415 if ExternalAutocomplete: 

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

417 return results 

418 

419 def create_object(self, value): 

420 """ 

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

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

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

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

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

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

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

428 model instance from the value... 

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

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

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

432 """ 

433 try: 

434 URLValidator()(value) 

435 return self.queryset.model.import_from(value) 

436 except ValidationError: 

437 pass 

438 try: 

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

440 except AttributeError: 

441 raise ImproperlyConfigured( 

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

443 ) 

444 

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

446 try: 

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

448 except Exception as e: 

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

450 

451 

452class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

453 template_name_suffix = "_import" 

454 permission_action_required = "add" 

455 

456 def get_form_class(self): 

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

458 form_class = first_member_match(form_modules, GenericImportForm) 

459 return modelform_factory(self.model, form_class) 

460 

461 def form_valid(self, form): 

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

463 return super().form_valid(form) 

464 

465 def get_success_url(self): 

466 return self.object.get_absolute_url() 

467 

468 

469class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

470 """ 

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

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

473 """ 

474 

475 template_name_suffix = "_selectmergeorenrich" 

476 permission_action_required = "add" 

477 form_class = GenericSelectMergeOrEnrichForm 

478 

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

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

481 

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

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

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

485 return context 

486 

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

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

489 kwargs["content_type"] = ContentType.objects.get_for_model(self.model) 

490 return kwargs 

491 

492 def form_valid(self, form): 

493 uri = form.cleaned_data["uri"] 

494 if uri.isdigit(): 

495 return redirect(self.get_object().get_merge_url(uri)) 

496 return redirect(self.get_object().get_enrich_url() + f"?uri={uri}") 

497 

498 

499class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

500 """ 

501 Generic merge view. 

502 """ 

503 

504 permission_action_required = "change" 

505 form_class = GenericMergeWithForm 

506 template_name_suffix = "_merge" 

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.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"]) 

512 

513 def get_context_data(self, **kwargs): 

514 """ 

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

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

517 a table with diffs 

518 """ 

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

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

521 ctx["changes"] = [] 

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

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

524 ctx["changes"].append( 

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

526 ) 

527 ctx["object"] = self.object 

528 ctx["other"] = self.other 

529 return ctx 

530 

531 def form_valid(self, form): 

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

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

534 return super().form_valid(form) 

535 

536 def get_success_url(self): 

537 return self.object.get_absolute_url() 

538 

539 

540class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

541 """ 

542 Enrich an entity with data from an external source 

543 Provides the user with a form to select the fields that should be updated. 

544 """ 

545 

546 permission_action_required = "change" 

547 template_name_suffix = "_enrich" 

548 form_class = GenericEnrichForm 

549 importer_class = None 

550 

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

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

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

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

555 if not self.uri: 

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

557 

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

559 try: 

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

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

562 messages.info( 

563 self.request, 

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

565 ) 

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

567 except Uri.DoesNotExist: 

568 pass 

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

570 

571 def get_context_data(self, **kwargs): 

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

573 ctx["object"] = self.object 

574 ctx["uri"] = self.uri 

575 return ctx 

576 

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

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

579 kwargs["instance"] = self.object 

580 try: 

581 self.data = self.model.fetch_from(self.uri) 

582 kwargs["data"] = self.data 

583 except ImproperlyConfigured as e: 

584 messages.error(self.request, e) 

585 return kwargs 

586 

587 def form_valid(self, form): 

588 """ 

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

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

591 select which fields to update). 

592 Create a dict from those values, add the uri and pass the dict on to 

593 the models `import_data` method. 

594 """ 

595 data = {} 

596 for key, values in self.request.POST.items(): 

597 if key.startswith("update_"): 

598 key = key.removeprefix("update_") 

599 data[key] = self.data[key] 

600 data["_uris"] = [self.uri] 

601 if data: 

602 errors = self.object.import_data(data) 

603 for field, error in errors.items(): 

604 messages.error(self.request, f"Could not update {field}: {error}") 

605 messages.info(self.request, f"Updated fields {data.keys()}") 

606 return super().form_valid(form) 

607 

608 def get_success_url(self): 

609 return self.object.get_absolute_url()