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

308 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-09-03 06:15 +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.shortcuts import get_object_or_404, redirect 

18from django.template.exceptions import TemplateDoesNotExist 

19from django.template.loader import select_template 

20from django.urls import reverse 

21from django.views import View 

22from django.views.generic import DetailView 

23from django.views.generic.base import TemplateView 

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

25from django_filters.filterset import filterset_factory 

26from django_filters.views import FilterView 

27from django_tables2 import SingleTableMixin 

28from django_tables2.columns import library 

29from django_tables2.export.views import ExportMixin 

30from django_tables2.tables import table_factory 

31 

32from apis_core.uris.models import Uri 

33from apis_core.uris.utils import create_object_from_uri 

34from apis_core.utils.helpers import get_importer_for_model 

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) 

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 # we look at the selected columns and exclude 

145 # all modelfields that are not part of that list 

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

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

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

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

150 kwargs["exclude"] = [ 

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

152 ] 

153 

154 # now we look at the selected columns and 

155 # add all modelfields and annotated fields that 

156 # are part of the selected columns to the extra_columns 

157 annotationfields = list() 

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

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

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

161 setattr(fake_field, "name", key) 

162 annotationfields.append(fake_field) 

163 extra_fields = list( 

164 filter( 

165 lambda x: x.name in selected_columns, 

166 modelfields + tuple(annotationfields), 

167 ) 

168 ) 

169 kwargs["extra_columns"] = [ 

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

171 for field in extra_fields 

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

173 ] 

174 

175 return kwargs 

176 

177 def get_filterset_class(self): 

178 filterset_modules = module_paths( 

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

180 ) 

181 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

182 return filterset_factory(self.model, filterset_class) 

183 

184 def _get_columns_choices(self, columns_exclude): 

185 # we start with the model fields 

186 choices = [ 

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

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

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

190 and not isinstance(field, ManyToManyRel) 

191 ] 

192 # we add any annotated fields to that 

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

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

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

196 return choices 

197 

198 def _get_columns_initial(self, columns_exclude): 

199 return [ 

200 field 

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

202 if field not in columns_exclude 

203 ] 

204 

205 def get_filterset(self, filterset_class): 

206 """ 

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

208 `columns` selector into the form 

209 """ 

210 filterset = super().get_filterset(filterset_class) 

211 columns_exclude = filterset.form.columns_exclude 

212 

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

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

215 columns = forms.MultipleChoiceField( 

216 required=False, 

217 choices=choices, 

218 initial=self._get_columns_initial(columns_exclude), 

219 ) 

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

221 # If the filterset form contains form data 

222 # we add a CSS class to the element wrapping 

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

224 # used to emphasize the fields that are used. 

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

226 # data, we create a temporary mapping between 

227 # widget_names and fields 

228 fields = {} 

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

230 fields[name] = name 

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

232 for widget_name in field.widget.widgets_names: 

233 fields[name + widget_name] = name 

234 if data := filterset.form.data: 

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

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

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

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

239 ) 

240 

241 return filterset 

242 

243 def get_queryset(self): 

244 queryset_methods = module_paths( 

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

246 ) 

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

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

249 

250 def get_table_pagination(self, table): 

251 """ 

252 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

254 """ 

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

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

257 return super().get_table_pagination(table) 

258 

259 

260class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

261 """ 

262 Detail view for a generic model. 

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

264 """ 

265 

266 permission_action_required = "view" 

267 

268 

269class Create( 

270 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView 

271): 

272 """ 

273 Create view for a generic model. 

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

275 The form class is overridden by the first match from 

276 the `first_member_match` helper. 

277 """ 

278 

279 template_name_suffix = "_create" 

280 permission_action_required = "add" 

281 

282 def get_form_class(self): 

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

284 form_class = first_member_match(form_modules, GenericModelForm) 

285 return modelform_factory(self.model, form_class) 

286 

287 def get_success_message(self, cleaned_data): 

288 message_templates = template_names_via_mro( 

289 self.model, "_create_success_message.html" 

290 ) 

291 template = select_template(message_templates) 

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

293 

294 def get_success_url(self): 

295 return self.object.get_create_success_url() 

296 

297 

298class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

299 """ 

300 Delete view for a generic model. 

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

302 """ 

303 

304 permission_action_required = "delete" 

305 

306 def get_success_url(self): 

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

308 return redirect 

309 return reverse( 

310 "apis_core:generic:list", 

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

312 ) 

313 

314 

315class Update( 

316 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView 

317): 

318 """ 

319 Update view for a generic model. 

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

321 The form class is overridden by the first match from 

322 the `first_member_match` helper. 

323 """ 

324 

325 permission_action_required = "change" 

326 

327 def get_form_class(self): 

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

329 form_class = first_member_match(form_modules, GenericModelForm) 

330 return modelform_factory(self.model, form_class) 

331 

332 def get_success_message(self, cleaned_data): 

333 message_templates = template_names_via_mro( 

334 self.model, "_update_success_message.html" 

335 ) 

336 template = select_template(message_templates) 

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

338 

339 def get_success_url(self): 

340 return self.object.get_update_success_url() 

341 

342 

343class Duplicate(GenericModelMixin, PermissionRequiredMixin, View): 

344 permission_action_required = "add" 

345 

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

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

348 newobj = source_obj.duplicate() 

349 

350 message_templates = template_names_via_mro( 

351 self.model, "_duplicate_success_message.html" 

352 ) 

353 template = select_template(message_templates) 

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

355 return redirect(newobj.get_edit_url()) 

356 

357 

358class Autocomplete( 

359 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

360): 

361 """ 

362 Autocomplete view for a generic model. 

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

364 The queryset is overridden by the first match from 

365 the `first_member_match` helper. 

366 """ 

367 

368 permission_action_required = "view" 

369 template_name_suffix = "_autocomplete_result" 

370 

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

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

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

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

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

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

377 try: 

378 template = select_template(self.get_template_names()) 

379 self.template = template.template.name 

380 except TemplateDoesNotExist: 

381 self.template = None 

382 

383 def get_queryset(self): 

384 queryset_methods = module_paths( 

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

386 ) 

387 queryset = first_member_match(queryset_methods) 

388 if queryset: 

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

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

391 

392 def get_results(self, context): 

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

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

395 queryset_methods = module_paths( 

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

397 ) 

398 ExternalAutocomplete = first_member_match(queryset_methods) 

399 if ExternalAutocomplete: 

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

401 return results 

402 

403 def create_object(self, value): 

404 """ 

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

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

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

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

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

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

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

412 model instance from the value... 

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

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

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

416 """ 

417 try: 

418 URLValidator()(value) 

419 return create_object_from_uri( 

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

421 ) 

422 except ValidationError: 

423 pass 

424 try: 

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

426 except AttributeError: 

427 raise ImproperlyConfigured( 

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

429 ) 

430 

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

432 try: 

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

434 except Exception as e: 

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

436 

437 

438class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

439 template_name_suffix = "_import" 

440 permission_action_required = "add" 

441 

442 def get_form_class(self): 

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

444 form_class = first_member_match(form_modules, GenericImportForm) 

445 return modelform_factory(self.model, form_class) 

446 

447 def form_valid(self, form): 

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

449 return super().form_valid(form) 

450 

451 def get_success_url(self): 

452 return self.object.get_absolute_url() 

453 

454 

455class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

456 """ 

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

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

459 """ 

460 

461 template_name_suffix = "_selectmergeorenrich" 

462 permission_action_required = "add" 

463 form_class = GenericSelectMergeOrEnrichForm 

464 

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

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

467 

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

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

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

471 return context 

472 

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

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

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

476 return kwargs 

477 

478 

479class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

480 """ 

481 Generic merge view. 

482 """ 

483 

484 permission_action_required = "change" 

485 form_class = GenericMergeWithForm 

486 template_name_suffix = "_merge" 

487 

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

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

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

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

492 

493 def get_context_data(self, **kwargs): 

494 """ 

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

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

497 a table with diffs 

498 """ 

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

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

501 ctx["changes"] = [] 

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

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

504 ctx["changes"].append( 

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

506 ) 

507 ctx["object"] = self.object 

508 ctx["other"] = self.other 

509 return ctx 

510 

511 def form_valid(self, form): 

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

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

514 return super().form_valid(form) 

515 

516 def get_success_url(self): 

517 return self.object.get_absolute_url() 

518 

519 

520class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

521 """ 

522 Enrich an entity with data from an external source 

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

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

525 """ 

526 

527 permission_action_required = "change" 

528 template_name_suffix = "_enrich" 

529 form_class = GenericEnrichForm 

530 importer_class = None 

531 

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

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

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

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

536 if not self.uri: 

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

538 self.importer_class = get_importer_for_model(self.model) 

539 

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

541 if self.uri.isdigit(): 

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

543 try: 

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

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

546 messages.info( 

547 self.request, 

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

549 ) 

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

551 except Uri.DoesNotExist: 

552 pass 

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

554 

555 def get_context_data(self, **kwargs): 

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

557 ctx["object"] = self.object 

558 ctx["uri"] = self.uri 

559 return ctx 

560 

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

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

563 kwargs["instance"] = self.object 

564 try: 

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

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

567 except ImproperlyConfigured as e: 

568 messages.error(self.request, e) 

569 return kwargs 

570 

571 def form_valid(self, form): 

572 """ 

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

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

575 select which fields to update). 

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

577 fields values on the model instance. 

578 """ 

579 update_fields = [ 

580 key.removeprefix("update_") 

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

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

583 ] 

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

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

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

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

588 uri, created = Uri.objects.get_or_create( 

589 uri=importer.get_uri, 

590 content_type=content_type, 

591 object_id=self.object.id, 

592 ) 

593 if created: 

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

595 return super().form_valid(form) 

596 

597 def get_success_url(self): 

598 return self.object.get_absolute_url()