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

326 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-10-30 12:03 +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 GenericModelPermissionRequiredMixin(PermissionRequiredMixin): 

59 """ 

60 Verify that the current user has the required permission for this model. 

61 The model overrides the `PermissionRequiredMixin.get_permission_required` 

62 method to generate the required permission name on the fly, based on a 

63 verb (`permission_action_required`) and the model this view act upon. 

64 This allows us to set `permission_action_required` simply to `add`, or 

65 `view` and reuse the mixin for views that work with different models. 

66 In addition, for the views that have `permission_action_required` set to 

67 `view`, it check if there is the global setting `APIS_ANON_VIEWS_ALLOWED` 

68 set to `True`, which permits anonymouse users access to the view. 

69 """ 

70 

71 def get_permission_required(self): 

72 if not hasattr(self, "model"): 

73 raise ImproperlyConfigured( 

74 f"{self.__class__.__name__} is missing the model attribute" 

75 ) 

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

77 settings, "APIS_ANON_VIEWS_ALLOWED", False 

78 ): 

79 return [] 

80 if hasattr(self, "permission_action_required"): 

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

82 return [] 

83 

84 

85class GenericModelMixin: 

86 """ 

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

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

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

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

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

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

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

94 """ 

95 

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

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

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

99 self.model = contenttype.model_class() 

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

101 

102 def get_template_names(self): 

103 template_names = [] 

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

105 # Some parent classes come with custom template_names, 

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

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

108 # gracefully 

109 try: 

110 template_names = super().get_template_names() 

111 except ImproperlyConfigured: 

112 pass 

113 suffix = ".html" 

114 if hasattr(self, "template_name_suffix"): 

115 suffix = self.template_name_suffix + ".html" 

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

117 template_names += filter( 

118 lambda template: template not in template_names, additional_templates 

119 ) 

120 return template_names 

121 

122 

123class List( 

124 GenericModelMixin, 

125 GenericModelPermissionRequiredMixin, 

126 ExportMixin, 

127 SingleTableMixin, 

128 FilterView, 

129): 

130 """ 

131 List view for a generic model. 

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

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

134 The table class is overridden by the first match from 

135 the `first_member_match` helper. 

136 The filterset class is overridden by the first match from 

137 the `first_member_match` helper. 

138 The queryset is overridden by the first match from 

139 the `first_member_match` helper. 

140 """ 

141 

142 template_name_suffix = "_list" 

143 permission_action_required = "view" 

144 

145 def get_table_class(self): 

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

147 table_class = first_member_match(table_modules, GenericTable) 

148 return table_factory(self.model, table_class) 

149 

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

151 

152 def get_export_filename(self, extension): 

153 table_class = self.get_table_class() 

154 if hasattr(table_class, "export_filename"): 

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

156 

157 return super().get_export_filename(extension) 

158 

159 def get_table_kwargs(self): 

160 kwargs = super().get_table_kwargs() 

161 

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

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

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

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

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

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

168 other_columns = [ 

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

170 ] 

171 kwargs["exclude"] = [ 

172 field for field in other_columns if field not in selected_columns 

173 ] 

174 

175 # now we look at the selected columns and 

176 # add all modelfields and annotated fields that 

177 # are part of the selected columns to the extra_columns 

178 annotationfields = list() 

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

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

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

182 setattr(fake_field, "name", key) 

183 annotationfields.append(fake_field) 

184 extra_fields = list( 

185 filter( 

186 lambda x: x.name in selected_columns, 

187 modelfields + tuple(annotationfields), 

188 ) 

189 ) 

190 kwargs["extra_columns"] = [ 

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

192 for field in extra_fields 

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

194 ] 

195 

196 return kwargs 

197 

198 def get_filterset_class(self): 

199 filterset_modules = module_paths( 

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

201 ) 

202 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

203 return filterset_factory(self.model, filterset_class) 

204 

205 def _get_columns_choices(self, columns_exclude): 

206 # we start with the model fields 

207 choices = [ 

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

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

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

211 and not isinstance(field, ManyToManyRel) 

212 ] 

213 # we add any annotated fields to that 

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

215 # lets also add the custom table fields 

216 choices += [ 

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

218 for key in self.get_table().columns 

219 ] 

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

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

222 return choices 

223 

224 def get_filterset_kwargs(self, filterset_class): 

225 columns_exclude = filterset_class.Meta.form.columns_exclude 

226 table_columns = self.get_table().columns 

227 initial_columns = [ 

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

229 ] 

230 kwargs = super().get_filterset_kwargs(filterset_class) 

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

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

233 data.setlist("columns", initial_columns) 

234 kwargs["data"] = data 

235 return kwargs 

236 

237 def get_filterset(self, filterset_class): 

238 """ 

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

240 `columns` selector into the form 

241 """ 

242 filterset = super().get_filterset(filterset_class) 

243 columns_exclude = filterset.form.columns_exclude 

244 

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

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

247 columns = forms.MultipleChoiceField( 

248 required=False, 

249 choices=choices, 

250 ) 

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

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

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

254 filterset.form 

255 ) 

256 # If the filterset form contains form data 

257 # we add a CSS class to the element wrapping 

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

259 # used to emphasize the fields that are used. 

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

261 # data, we create a temporary mapping between 

262 # widget_names and fields 

263 fields = {} 

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

265 fields[name] = name 

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

267 for widget_name in field.widget.widgets_names: 

268 fields[name + widget_name] = name 

269 if data := filterset.form.data: 

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

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

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

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

274 ) 

275 

276 return filterset 

277 

278 def get_queryset(self): 

279 queryset_methods = module_paths( 

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

281 ) 

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

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

284 

285 def get_table_pagination(self, table): 

286 """ 

287 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

289 """ 

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

291 return super().get_table_pagination(table) 

292 

293 

294class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView): 

295 """ 

296 Detail view for a generic model. 

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

298 """ 

299 

300 permission_action_required = "view" 

301 

302 

303class Create( 

304 GenericModelMixin, 

305 GenericModelPermissionRequiredMixin, 

306 SuccessMessageMixin, 

307 CreateView, 

308): 

309 """ 

310 Create view for a generic model. 

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

312 The form class is overridden by the first match from 

313 the `first_member_match` helper. 

314 """ 

315 

316 template_name_suffix = "_create" 

317 permission_action_required = "add" 

318 

319 def get_form_class(self): 

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

321 form_class = first_member_match(form_modules, GenericModelForm) 

322 return modelform_factory(self.model, form_class) 

323 

324 def get_success_message(self, cleaned_data): 

325 message_templates = template_names_via_mro( 

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

327 ) 

328 template = select_template(message_templates) 

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

330 

331 def get_success_url(self): 

332 return self.object.get_create_success_url() 

333 

334 

335class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView): 

336 """ 

337 Delete view for a generic model. 

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

339 """ 

340 

341 permission_action_required = "delete" 

342 

343 def get_success_url(self): 

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

345 return redirect 

346 return reverse( 

347 "apis_core:generic:list", 

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

349 ) 

350 

351 

352class Update( 

353 GenericModelMixin, 

354 GenericModelPermissionRequiredMixin, 

355 SuccessMessageMixin, 

356 UpdateView, 

357): 

358 """ 

359 Update view for a generic model. 

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

361 The form class is overridden by the first match from 

362 the `first_member_match` helper. 

363 """ 

364 

365 permission_action_required = "change" 

366 

367 def get_form_class(self): 

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

369 form_class = first_member_match(form_modules, GenericModelForm) 

370 return modelform_factory(self.model, form_class) 

371 

372 def get_success_message(self, cleaned_data): 

373 message_templates = template_names_via_mro( 

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

375 ) 

376 template = select_template(message_templates) 

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

378 

379 def get_success_url(self): 

380 return self.object.get_update_success_url() 

381 

382 

383class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View): 

384 permission_action_required = "add" 

385 

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

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

388 newobj = source_obj.duplicate() 

389 

390 message_templates = template_names_via_mro( 

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

392 ) 

393 template = select_template(message_templates) 

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

395 return redirect(newobj.get_edit_url()) 

396 

397 

398class Autocomplete( 

399 GenericModelMixin, 

400 GenericModelPermissionRequiredMixin, 

401 autocomplete.Select2QuerySetView, 

402): 

403 """ 

404 Autocomplete view for a generic model. 

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

406 The queryset is overridden by the first match from 

407 the `first_member_match` helper. 

408 """ 

409 

410 permission_action_required = "view" 

411 template_name_suffix = "_autocomplete_result" 

412 

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

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

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

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

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

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

419 try: 

420 template = select_template(self.get_template_names()) 

421 self.template = template.template.name 

422 except TemplateDoesNotExist: 

423 self.template = None 

424 

425 def get_queryset(self): 

426 queryset_methods = module_paths( 

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

428 ) 

429 queryset = first_member_match(queryset_methods) 

430 if queryset: 

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

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

433 

434 def get_results(self, context): 

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

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

437 queryset_methods = module_paths( 

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

439 ) 

440 ExternalAutocomplete = first_member_match(queryset_methods) 

441 if ExternalAutocomplete: 

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

443 return results 

444 

445 def create_object(self, value): 

446 """ 

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

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

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

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

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

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

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

454 model instance from the value... 

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

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

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

458 """ 

459 try: 

460 URLValidator()(value) 

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

462 except ValidationError: 

463 pass 

464 try: 

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

466 except AttributeError: 

467 raise ImproperlyConfigured( 

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

469 ) 

470 

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

472 try: 

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

474 except Exception as e: 

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

476 

477 

478class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

479 template_name_suffix = "_import" 

480 permission_action_required = "add" 

481 

482 def get_form_class(self): 

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

484 form_class = first_member_match(form_modules, GenericImportForm) 

485 return modelform_factory(self.model, form_class) 

486 

487 def form_valid(self, form): 

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

489 return super().form_valid(form) 

490 

491 def get_success_url(self): 

492 return self.object.get_absolute_url() 

493 

494 

495class SelectMergeOrEnrich( 

496 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView 

497): 

498 """ 

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

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

501 """ 

502 

503 template_name_suffix = "_selectmergeorenrich" 

504 permission_action_required = "add" 

505 form_class = GenericSelectMergeOrEnrichForm 

506 

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

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

509 

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

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

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

513 return context 

514 

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

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

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

518 return kwargs 

519 

520 def form_valid(self, form): 

521 uri = form.cleaned_data["uri"] 

522 if uri.isdigit(): 

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

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

525 

526 

527class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

528 """ 

529 Generic merge view. 

530 """ 

531 

532 permission_action_required = "change" 

533 form_class = GenericMergeWithForm 

534 template_name_suffix = "_merge" 

535 

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

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

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

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

540 

541 def get_context_data(self, **kwargs): 

542 """ 

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

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

545 a table with diffs 

546 """ 

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

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

549 ctx["changes"] = [] 

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

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

552 ctx["changes"].append( 

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

554 ) 

555 ctx["object"] = self.object 

556 ctx["other"] = self.other 

557 return ctx 

558 

559 def form_valid(self, form): 

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

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

562 return super().form_valid(form) 

563 

564 def get_success_url(self): 

565 return self.object.get_absolute_url() 

566 

567 

568class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

569 """ 

570 Enrich an entity with data from an external source 

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

572 """ 

573 

574 permission_action_required = "change" 

575 template_name_suffix = "_enrich" 

576 form_class = GenericEnrichForm 

577 importer_class = None 

578 

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

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

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

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

583 if not self.uri: 

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

585 

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

587 try: 

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

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

590 messages.info( 

591 self.request, 

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

593 ) 

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

595 except Uri.DoesNotExist: 

596 pass 

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

598 

599 def get_context_data(self, **kwargs): 

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

601 ctx["object"] = self.object 

602 ctx["uri"] = self.uri 

603 return ctx 

604 

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

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

607 kwargs["instance"] = self.object 

608 try: 

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

610 kwargs["data"] = self.data 

611 except ImproperlyConfigured as e: 

612 messages.error(self.request, e) 

613 return kwargs 

614 

615 def form_valid(self, form): 

616 """ 

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

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

619 select which fields to update). 

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

621 the models `import_data` method. 

622 """ 

623 data = {} 

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

625 if key.startswith("update_"): 

626 key = key.removeprefix("update_") 

627 data[key] = self.data[key] 

628 data["same_as"] = [self.uri] + data.get("same_as", []) 

629 if data: 

630 errors = self.object.import_data(data) 

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

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

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

634 return super().form_valid(form) 

635 

636 def get_success_url(self): 

637 return self.object.get_absolute_url()