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

334 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-12-04 11:32 +0000

1import logging 

2import traceback 

3from collections import namedtuple 

4from copy import copy 

5 

6from crispy_forms.layout import Field 

7from dal import autocomplete 

8from django import http 

9from django.conf import settings 

10from django.contrib import messages 

11from django.contrib.auth.mixins import PermissionRequiredMixin 

12from django.contrib.contenttypes.models import ContentType 

13from django.contrib.messages.views import SuccessMessageMixin 

14from django.core.exceptions import ImproperlyConfigured, ValidationError 

15from django.core.validators import URLValidator 

16from django.db import transaction 

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

18from django.forms import modelform_factory 

19from django.forms.utils import pretty_name 

20from django.shortcuts import get_object_or_404, redirect 

21from django.template.exceptions import TemplateDoesNotExist 

22from django.template.loader import select_template 

23from django.urls import reverse 

24from django.utils.text import capfirst 

25from django.views import View 

26from django.views.generic import DetailView 

27from django.views.generic.base import TemplateView 

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

29from django_filters.filterset import filterset_factory 

30from django_filters.views import FilterView 

31from django_tables2 import SingleTableMixin 

32from django_tables2.columns import library 

33from django_tables2.export.views import ExportMixin 

34from django_tables2.tables import table_factory 

35 

36from apis_core.generic.utils import get_autocomplete_data_and_normalized_uri 

37from apis_core.uris.models import Uri 

38 

39from .filtersets import GenericFilterSet 

40from .forms import ( 

41 ColumnsSelectorForm, 

42 GenericEnrichForm, 

43 GenericImportForm, 

44 GenericMergeWithForm, 

45 GenericModelForm, 

46 GenericSelectMergeOrEnrichForm, 

47) 

48from .helpers import ( 

49 first_member_match, 

50 generate_search_filter, 

51 module_paths, 

52 permission_fullname, 

53 template_names_via_mro, 

54) 

55from .tables import GenericTable 

56 

57logger = logging.getLogger(__name__) 

58 

59 

60class Overview(TemplateView): 

61 template_name = "generic/overview.html" 

62 

63 

64class GenericModelPermissionRequiredMixin(PermissionRequiredMixin): 

65 """ 

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

67 The model overrides the `PermissionRequiredMixin.get_permission_required` 

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

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

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

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

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

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

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

75 """ 

76 

77 def get_permission_required(self): 

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

79 raise ImproperlyConfigured( 

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

81 ) 

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

83 settings, "APIS_ANON_VIEWS_ALLOWED", False 

84 ): 

85 return [] 

86 if hasattr(self, "permission_action_required"): 

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

88 return [] 

89 

90 

91class GenericModelMixin: 

92 """ 

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

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

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

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

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

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

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

100 """ 

101 

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

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

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

105 self.model = contenttype.model_class() 

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

107 

108 def get_template_names(self): 

109 template_names = [] 

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

111 # Some parent classes come with custom template_names, 

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

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

114 # gracefully 

115 try: 

116 template_names = super().get_template_names() 

117 except ImproperlyConfigured: 

118 pass 

119 suffix = ".html" 

120 if hasattr(self, "template_name_suffix"): 

121 suffix = self.template_name_suffix + ".html" 

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

123 template_names += filter( 

124 lambda template: template not in template_names, additional_templates 

125 ) 

126 return template_names 

127 

128 

129class List( 

130 GenericModelMixin, 

131 GenericModelPermissionRequiredMixin, 

132 ExportMixin, 

133 SingleTableMixin, 

134 FilterView, 

135): 

136 """ 

137 List view for a generic model. 

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

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

140 The table class is overridden by the first match from 

141 the `first_member_match` helper. 

142 The filterset class is overridden by the first match from 

143 the `first_member_match` helper. 

144 The queryset is overridden by the first match from 

145 the `first_member_match` helper. 

146 """ 

147 

148 template_name_suffix = "_list" 

149 permission_action_required = "view" 

150 

151 def get_table_class(self): 

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

153 table_class = first_member_match(table_modules, GenericTable) 

154 return table_factory(self.model, table_class) 

155 

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

157 

158 def get_export_filename(self, extension): 

159 table_class = self.get_table_class() 

160 if hasattr(table_class, "export_filename"): 

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

162 

163 return super().get_export_filename(extension) 

164 

165 def get_table_kwargs(self): 

166 kwargs = super().get_table_kwargs() 

167 

168 selected_columns = self.request.GET.getlist("choices-columns", []) 

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

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

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

172 if self.request.GET and "choices-columns" in self.request.GET: 

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

174 other_columns = [ 

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

176 ] 

177 kwargs["exclude"] = [ 

178 field for field in other_columns if field not in selected_columns 

179 ] 

180 

181 # now we look at the selected columns and 

182 # add all modelfields and annotated fields that 

183 # are part of the selected columns to the extra_columns 

184 annotationfields = list() 

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

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

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

188 setattr(fake_field, "name", key) 

189 annotationfields.append(fake_field) 

190 extra_fields = list( 

191 filter( 

192 lambda x: x.name in selected_columns, 

193 modelfields + tuple(annotationfields), 

194 ) 

195 ) 

196 kwargs["extra_columns"] = [ 

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

198 for field in extra_fields 

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

200 ] 

201 

202 return kwargs 

203 

204 def get_filterset_class(self): 

205 filterset_modules = module_paths( 

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

207 ) 

208 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

209 return filterset_factory(self.model, filterset_class) 

210 

211 def _get_columns_choices(self, columns_exclude): 

212 # lets start with the custom table fields 

213 choices = { 

214 key.name: capfirst(str(key) or key.name or "Nameless column") 

215 for key in self.get_table().columns 

216 } 

217 # then add the model fields, but only the ones 

218 # that are not automatically created (parent keys) 

219 # and not the m2m relations and not any that are 

220 # already part of the choices 

221 choices |= { 

222 field.name: pretty_name(getattr(field, "verbose_name", field.name)) 

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

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

225 and not isinstance(field, ManyToManyRel) 

226 and field.name not in choices.keys() 

227 } 

228 # finally we add any annotated fields 

229 choices |= {key: key for key in self.get_queryset().query.annotations.keys()} 

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

231 choices = { 

232 key: value for key, value in choices.items() if key not in columns_exclude 

233 } 

234 return choices.items() 

235 

236 def get_filterset_kwargs(self, filterset_class): 

237 kwargs = super().get_filterset_kwargs(filterset_class) 

238 kwargs["prefix"] = "filterset" 

239 return kwargs 

240 

241 def get_filterset(self, filterset_class): 

242 """ 

243 We override the `get_filterset` method, so we can add a 

244 css class to the the selected filters 

245 """ 

246 filterset = super().get_filterset(filterset_class) 

247 

248 # If the filterset form contains form data 

249 # we add a CSS class to the element wrapping 

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

251 # used to emphasize the fields that are used. 

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

253 # data, we create a temporary mapping between 

254 # widget_names and fields 

255 fields = {} 

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

257 fields[name] = name 

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

259 for widget_name in field.widget.widgets_names: 

260 fields[name + widget_name] = name 

261 if filterset.form.is_valid(): 

262 data = filterset.form.cleaned_data 

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

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

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

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

267 ) 

268 

269 return filterset 

270 

271 def get_queryset(self): 

272 queryset_methods = module_paths( 

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

274 ) 

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

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

277 

278 def get_table_pagination(self, table): 

279 """ 

280 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

282 """ 

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

284 return super().get_table_pagination(table) 

285 

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

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

288 table = context.get("table", None) 

289 filterset = context.get("filter", None) 

290 if table and filterset: 

291 columns_exclude = filterset.form.columns_exclude 

292 initial_columns = [ 

293 col.name for col in table.columns if col.name not in columns_exclude 

294 ] 

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

296 context["columns_selector"] = ColumnsSelectorForm( 

297 choices=choices, 

298 initial={"choices": initial_columns}, 

299 prefix="choices", 

300 ) 

301 return context 

302 

303 

304class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView): 

305 """ 

306 Detail view for a generic model. 

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

308 """ 

309 

310 permission_action_required = "view" 

311 

312 

313class Create( 

314 GenericModelMixin, 

315 GenericModelPermissionRequiredMixin, 

316 SuccessMessageMixin, 

317 CreateView, 

318): 

319 """ 

320 Create view for a generic model. 

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

322 The form class is overridden by the first match from 

323 the `first_member_match` helper. 

324 """ 

325 

326 template_name_suffix = "_create" 

327 permission_action_required = "add" 

328 

329 def get_form_class(self): 

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

331 form_class = first_member_match(form_modules, GenericModelForm) 

332 return modelform_factory(self.model, form_class) 

333 

334 def get_success_message(self, cleaned_data): 

335 message_templates = template_names_via_mro( 

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

337 ) 

338 template = select_template(message_templates) 

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

340 

341 def get_success_url(self): 

342 return self.object.get_create_success_url() 

343 

344 

345class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView): 

346 """ 

347 Delete view for a generic model. 

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

349 """ 

350 

351 permission_action_required = "delete" 

352 

353 def get_success_url(self): 

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

355 return redirect 

356 return reverse( 

357 "apis_core:generic:list", 

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

359 ) 

360 

361 

362class Update( 

363 GenericModelMixin, 

364 GenericModelPermissionRequiredMixin, 

365 SuccessMessageMixin, 

366 UpdateView, 

367): 

368 """ 

369 Update view for a generic model. 

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

371 The form class is overridden by the first match from 

372 the `first_member_match` helper. 

373 """ 

374 

375 permission_action_required = "change" 

376 

377 def get_form_class(self): 

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

379 form_class = first_member_match(form_modules, GenericModelForm) 

380 return modelform_factory(self.model, form_class) 

381 

382 def get_success_message(self, cleaned_data): 

383 message_templates = template_names_via_mro( 

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

385 ) 

386 template = select_template(message_templates) 

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

388 

389 def get_success_url(self): 

390 return self.object.get_update_success_url() 

391 

392 

393class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View): 

394 permission_action_required = "add" 

395 

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

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

398 newobj = source_obj.duplicate() 

399 

400 message_templates = template_names_via_mro( 

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

402 ) 

403 template = select_template(message_templates) 

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

405 return redirect(newobj.get_edit_url()) 

406 

407 

408class Autocomplete( 

409 GenericModelMixin, 

410 GenericModelPermissionRequiredMixin, 

411 autocomplete.Select2QuerySetView, 

412): 

413 """ 

414 Autocomplete view for a generic model. 

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

416 The queryset is overridden by the first match from 

417 the `first_member_match` helper. 

418 """ 

419 

420 permission_action_required = "view" 

421 template_name_suffix = "_autocomplete_result" 

422 

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

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

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

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

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

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

429 try: 

430 template = select_template(self.get_template_names()) 

431 self.template = template.template.name 

432 except TemplateDoesNotExist: 

433 self.template = None 

434 

435 def get_queryset(self): 

436 queryset_methods = module_paths( 

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

438 ) 

439 queryset = first_member_match(queryset_methods) 

440 if queryset: 

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

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

443 

444 def get_results(self, context): 

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

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

447 queryset_methods = module_paths( 

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

449 ) 

450 ExternalAutocomplete = first_member_match(queryset_methods) 

451 if ExternalAutocomplete: 

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

453 return results 

454 

455 def create_object(self, value): 

456 """ 

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

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

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

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

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

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

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

464 model instance from the value... 

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

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

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

468 """ 

469 try: 

470 URLValidator()(value) 

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

472 except ValidationError: 

473 pass 

474 try: 

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

476 except AttributeError: 

477 raise ImproperlyConfigured( 

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

479 ) 

480 

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

482 try: 

483 with transaction.atomic(): 

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

485 except Exception as e: 

486 logger.debug(traceback.format_exc()) 

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

488 

489 

490class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

491 template_name_suffix = "_import" 

492 permission_action_required = "add" 

493 

494 def get_form_class(self): 

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

496 form_class = first_member_match(form_modules, GenericImportForm) 

497 return modelform_factory(self.model, form_class) 

498 

499 def form_valid(self, form): 

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

501 for field, error in getattr(self.object, "_import_errors", {}).items(): 

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

503 return super().form_valid(form) 

504 

505 def get_success_url(self): 

506 return self.object.get_absolute_url() 

507 

508 

509class SelectMergeOrEnrich( 

510 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView 

511): 

512 """ 

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

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

515 """ 

516 

517 template_name_suffix = "_selectmergeorenrich" 

518 permission_action_required = "add" 

519 form_class = GenericSelectMergeOrEnrichForm 

520 

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

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

523 

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

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

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

527 return context 

528 

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

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

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

532 return kwargs 

533 

534 def form_valid(self, form): 

535 uri = form.cleaned_data["uri"] 

536 if uri.isdigit(): 

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

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

539 

540 

541class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

542 """ 

543 Generic merge view. 

544 """ 

545 

546 permission_action_required = "change" 

547 form_class = GenericMergeWithForm 

548 template_name_suffix = "_merge" 

549 

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

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

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

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

554 

555 def get_context_data(self, **kwargs): 

556 """ 

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

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

559 a table with diffs 

560 """ 

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

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

563 ctx["changes"] = [] 

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

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

566 ctx["changes"].append( 

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

568 ) 

569 ctx["object"] = self.object 

570 ctx["other"] = self.other 

571 return ctx 

572 

573 def form_valid(self, form): 

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

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

576 return super().form_valid(form) 

577 

578 def get_success_url(self): 

579 return self.object.get_absolute_url() 

580 

581 

582class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

583 """ 

584 Enrich an entity with data from an external source 

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

586 """ 

587 

588 permission_action_required = "change" 

589 template_name_suffix = "_enrich" 

590 form_class = GenericEnrichForm 

591 importer_class = None 

592 

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

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

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

596 _, self.uri = get_autocomplete_data_and_normalized_uri( 

597 self.request.GET.get("uri") 

598 ) 

599 if not self.uri: 

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

601 

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

603 try: 

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

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

606 messages.info( 

607 self.request, 

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

609 ) 

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

611 except Uri.DoesNotExist: 

612 pass 

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

614 

615 def get_context_data(self, **kwargs): 

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

617 ctx["object"] = self.object 

618 ctx["uri"] = self.uri 

619 return ctx 

620 

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

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

623 kwargs["instance"] = self.object 

624 try: 

625 self.data = self.model.fetch_from(self.request.GET.get("uri")) 

626 kwargs["data"] = self.data 

627 except ImproperlyConfigured as e: 

628 messages.error(self.request, e) 

629 return kwargs 

630 

631 def form_valid(self, form): 

632 """ 

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

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

635 select which fields to update). 

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

637 the models `import_data` method. 

638 """ 

639 data = {} 

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

641 if key.startswith("update_"): 

642 key = key.removeprefix("update_") 

643 data[key] = self.data[key] 

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

645 if data: 

646 self.object.import_data(data) 

647 for field, error in getattr(self.object, "_import_errors", {}).items(): 

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

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

650 return super().form_valid(form) 

651 

652 def get_success_url(self): 

653 return self.object.get_absolute_url()