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

308 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-09-17 09:41 +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 # rebuild the layout, now that the columns field was added 

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

223 filterset.form 

224 ) 

225 # If the filterset form contains form data 

226 # we add a CSS class to the element wrapping 

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

228 # used to emphasize the fields that are used. 

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

230 # data, we create a temporary mapping between 

231 # widget_names and fields 

232 fields = {} 

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

234 fields[name] = name 

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

236 for widget_name in field.widget.widgets_names: 

237 fields[name + widget_name] = name 

238 if data := filterset.form.data: 

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

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

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

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

243 ) 

244 

245 return filterset 

246 

247 def get_queryset(self): 

248 queryset_methods = module_paths( 

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

250 ) 

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

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

253 

254 def get_table_pagination(self, table): 

255 """ 

256 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

258 """ 

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

260 return super().get_table_pagination(table) 

261 

262 

263class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): 

264 """ 

265 Detail view for a generic model. 

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

267 """ 

268 

269 permission_action_required = "view" 

270 

271 

272class Create( 

273 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView 

274): 

275 """ 

276 Create view for a generic model. 

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

278 The form class is overridden by the first match from 

279 the `first_member_match` helper. 

280 """ 

281 

282 template_name_suffix = "_create" 

283 permission_action_required = "add" 

284 

285 def get_form_class(self): 

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

287 form_class = first_member_match(form_modules, GenericModelForm) 

288 return modelform_factory(self.model, form_class) 

289 

290 def get_success_message(self, cleaned_data): 

291 message_templates = template_names_via_mro( 

292 self.model, "_create_success_message.html" 

293 ) 

294 template = select_template(message_templates) 

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

296 

297 def get_success_url(self): 

298 return self.object.get_create_success_url() 

299 

300 

301class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): 

302 """ 

303 Delete view for a generic model. 

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

305 """ 

306 

307 permission_action_required = "delete" 

308 

309 def get_success_url(self): 

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

311 return redirect 

312 return reverse( 

313 "apis_core:generic:list", 

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

315 ) 

316 

317 

318class Update( 

319 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView 

320): 

321 """ 

322 Update view for a generic model. 

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

324 The form class is overridden by the first match from 

325 the `first_member_match` helper. 

326 """ 

327 

328 permission_action_required = "change" 

329 

330 def get_form_class(self): 

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

332 form_class = first_member_match(form_modules, GenericModelForm) 

333 return modelform_factory(self.model, form_class) 

334 

335 def get_success_message(self, cleaned_data): 

336 message_templates = template_names_via_mro( 

337 self.model, "_update_success_message.html" 

338 ) 

339 template = select_template(message_templates) 

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

341 

342 def get_success_url(self): 

343 return self.object.get_update_success_url() 

344 

345 

346class Duplicate(GenericModelMixin, PermissionRequiredMixin, View): 

347 permission_action_required = "add" 

348 

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

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

351 newobj = source_obj.duplicate() 

352 

353 message_templates = template_names_via_mro( 

354 self.model, "_duplicate_success_message.html" 

355 ) 

356 template = select_template(message_templates) 

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

358 return redirect(newobj.get_edit_url()) 

359 

360 

361class Autocomplete( 

362 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView 

363): 

364 """ 

365 Autocomplete view for a generic model. 

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

367 The queryset is overridden by the first match from 

368 the `first_member_match` helper. 

369 """ 

370 

371 permission_action_required = "view" 

372 template_name_suffix = "_autocomplete_result" 

373 

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

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

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

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

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

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

380 try: 

381 template = select_template(self.get_template_names()) 

382 self.template = template.template.name 

383 except TemplateDoesNotExist: 

384 self.template = None 

385 

386 def get_queryset(self): 

387 queryset_methods = module_paths( 

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

389 ) 

390 queryset = first_member_match(queryset_methods) 

391 if queryset: 

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

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

394 

395 def get_results(self, context): 

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

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

398 queryset_methods = module_paths( 

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

400 ) 

401 ExternalAutocomplete = first_member_match(queryset_methods) 

402 if ExternalAutocomplete: 

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

404 return results 

405 

406 def create_object(self, value): 

407 """ 

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

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

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

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

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

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

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

415 model instance from the value... 

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

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

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

419 """ 

420 try: 

421 URLValidator()(value) 

422 return create_object_from_uri( 

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

424 ) 

425 except ValidationError: 

426 pass 

427 try: 

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

429 except AttributeError: 

430 raise ImproperlyConfigured( 

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

432 ) 

433 

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

435 try: 

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

437 except Exception as e: 

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

439 

440 

441class Import(GenericModelMixin, PermissionRequiredMixin, FormView): 

442 template_name_suffix = "_import" 

443 permission_action_required = "add" 

444 

445 def get_form_class(self): 

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

447 form_class = first_member_match(form_modules, GenericImportForm) 

448 return modelform_factory(self.model, form_class) 

449 

450 def form_valid(self, form): 

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

452 return super().form_valid(form) 

453 

454 def get_success_url(self): 

455 return self.object.get_absolute_url() 

456 

457 

458class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

459 """ 

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

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

462 """ 

463 

464 template_name_suffix = "_selectmergeorenrich" 

465 permission_action_required = "add" 

466 form_class = GenericSelectMergeOrEnrichForm 

467 

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

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

470 

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

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

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

474 return context 

475 

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

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

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

479 return kwargs 

480 

481 

482class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView): 

483 """ 

484 Generic merge view. 

485 """ 

486 

487 permission_action_required = "change" 

488 form_class = GenericMergeWithForm 

489 template_name_suffix = "_merge" 

490 

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

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

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

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

495 

496 def get_context_data(self, **kwargs): 

497 """ 

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

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

500 a table with diffs 

501 """ 

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

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

504 ctx["changes"] = [] 

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

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

507 ctx["changes"].append( 

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

509 ) 

510 ctx["object"] = self.object 

511 ctx["other"] = self.other 

512 return ctx 

513 

514 def form_valid(self, form): 

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

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

517 return super().form_valid(form) 

518 

519 def get_success_url(self): 

520 return self.object.get_absolute_url() 

521 

522 

523class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView): 

524 """ 

525 Enrich an entity with data from an external source 

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

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

528 """ 

529 

530 permission_action_required = "change" 

531 template_name_suffix = "_enrich" 

532 form_class = GenericEnrichForm 

533 importer_class = None 

534 

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

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

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

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

539 if not self.uri: 

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

541 self.importer_class = get_importer_for_model(self.model) 

542 

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

544 if self.uri.isdigit(): 

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

546 try: 

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

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

549 messages.info( 

550 self.request, 

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

552 ) 

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

554 except Uri.DoesNotExist: 

555 pass 

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

557 

558 def get_context_data(self, **kwargs): 

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

560 ctx["object"] = self.object 

561 ctx["uri"] = self.uri 

562 return ctx 

563 

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

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

566 kwargs["instance"] = self.object 

567 try: 

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

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

570 except ImproperlyConfigured as e: 

571 messages.error(self.request, e) 

572 return kwargs 

573 

574 def form_valid(self, form): 

575 """ 

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

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

578 select which fields to update). 

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

580 fields values on the model instance. 

581 """ 

582 update_fields = [ 

583 key.removeprefix("update_") 

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

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

586 ] 

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

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

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

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

591 uri, created = Uri.objects.get_or_create( 

592 uri=importer.get_uri, 

593 content_type=content_type, 

594 object_id=self.object.id, 

595 ) 

596 if created: 

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

598 return super().form_valid(form) 

599 

600 def get_success_url(self): 

601 return self.object.get_absolute_url()