Coverage for apis_core/generic/views.py: 39%
290 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +0000
1from collections import namedtuple
2from copy import copy
4from dal import autocomplete
5from django import forms, http
6from django.conf import settings
7from django.contrib import messages
8from django.contrib.auth.mixins import PermissionRequiredMixin
9from django.contrib.contenttypes.models import ContentType
10from django.core.exceptions import ImproperlyConfigured, ValidationError
11from django.core.validators import URLValidator
12from django.db.models.fields.related import ManyToManyRel
13from django.forms import modelform_factory
14from django.forms.utils import pretty_name
15from django.shortcuts import get_object_or_404, redirect
16from django.template.exceptions import TemplateDoesNotExist
17from django.template.loader import select_template
18from django.urls import reverse, reverse_lazy
19from django.utils.html import format_html
20from django.views import View
21from django.views.generic import DetailView
22from django.views.generic.base import TemplateView
23from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
24from django_filters.filterset import filterset_factory
25from django_filters.views import FilterView
26from django_tables2 import SingleTableMixin
27from django_tables2.columns import library
28from django_tables2.export.views import ExportMixin
29from django_tables2.tables import table_factory
31from apis_core.apis_metainfo.models import Uri
32from apis_core.apis_metainfo.utils import create_object_from_uri
33from apis_core.utils.helpers import get_importer_for_model
35from .filtersets import GenericFilterSet
36from .forms import (
37 GenericEnrichForm,
38 GenericImportForm,
39 GenericMergeWithForm,
40 GenericModelForm,
41 GenericSelectMergeOrEnrichForm,
42)
43from .helpers import (
44 first_member_match,
45 generate_search_filter,
46 module_paths,
47 permission_fullname,
48 template_names_via_mro,
49)
50from .tables import GenericTable
53class Overview(TemplateView):
54 template_name = "generic/overview.html"
57class GenericModelMixin:
58 """
59 A mixin providing the common functionality for all the views working
60 with `generic` models - that is models that are accessed via the
61 contenttype framework (using `app_label.model`).
62 It sets the `.model` of the view and generates a list of possible template
63 names (based on the MRO of the model).
64 If the view has a `permission_action_required` attribute, this is used
65 to set the permission required to access the view for this specific model.
66 """
68 def setup(self, *args, **kwargs):
69 super().setup(*args, **kwargs)
70 if contenttype := kwargs.get("contenttype"):
71 self.model = contenttype.model_class()
72 self.queryset = self.model.objects.all()
74 def get_template_names(self):
75 template_names = []
76 if hasattr(super(), "get_template_names"):
77 # Some parent classes come with custom template_names,
78 # some need a `.template_name` attribute set. For the
79 # latter ones we handle the missing `.template_name`
80 # gracefully
81 try:
82 template_names = super().get_template_names()
83 except ImproperlyConfigured:
84 pass
85 suffix = ".html"
86 if hasattr(self, "template_name_suffix"):
87 suffix = self.template_name_suffix + ".html"
88 additional_templates = template_names_via_mro(self.model, suffix) + [
89 f"generic/generic{suffix}"
90 ]
91 template_names += filter(
92 lambda template: template not in template_names, additional_templates
93 )
94 return template_names
96 def get_permission_required(self):
97 if getattr(self, "permission_action_required", None) == "view" and getattr(
98 settings, "APIS_ANON_VIEWS_ALLOWED", False
99 ):
100 return []
101 if hasattr(self, "permission_action_required"):
102 return [permission_fullname(self.permission_action_required, self.model)]
103 return []
106class List(
107 GenericModelMixin,
108 PermissionRequiredMixin,
109 ExportMixin,
110 SingleTableMixin,
111 FilterView,
112):
113 """
114 List view for a generic model.
115 Access requires the `<model>_view` permission.
116 It is based on django-filters FilterView and django-tables SingleTableMixin.
117 The table class is overridden by the first match from
118 the `first_member_match` helper.
119 The filterset class is overridden by the first match from
120 the `first_member_match` helper.
121 The queryset is overridden by the first match from
122 the `first_member_match` helper.
123 """
125 template_name_suffix = "_list"
126 permission_action_required = "view"
128 def get_table_class(self):
129 table_modules = module_paths(self.model, path="tables", suffix="Table")
130 table_class = first_member_match(table_modules, GenericTable)
131 return table_factory(self.model, table_class)
133 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
135 def get_export_filename(self, extension):
136 table_class = self.get_table_class()
137 if hasattr(table_class, "export_filename"):
138 return f"{table_class.export_filename}.{extension}"
140 return super().get_export_filename(extension)
142 def get_table_kwargs(self):
143 kwargs = super().get_table_kwargs()
145 # we look at the selected columns and exclude
146 # all modelfields that are not part of that list
147 form = self.get_filterset(self.get_filterset_class()).form
148 initial = form.fields["columns"].initial if "columns" in form.fields else []
149 selected_columns = self.request.GET.getlist("columns", initial)
150 modelfields = self.model._meta.get_fields()
151 kwargs["exclude"] = [
152 field.name for field in modelfields if field.name not in selected_columns
153 ]
155 # now we look at the selected columns and
156 # add all modelfields and annotated fields that
157 # are part of the selected columns to the extra_columns
158 annotationfields = list()
159 for key, value in self.object_list.query.annotations.items():
160 # we have to use copy, so we don't edit the original field
161 fake_field = copy(getattr(value, "field", value.output_field))
162 setattr(fake_field, "name", key)
163 annotationfields.append(fake_field)
164 extra_fields = list(
165 filter(
166 lambda x: x.name in selected_columns,
167 modelfields + tuple(annotationfields),
168 )
169 )
170 kwargs["extra_columns"] = [
171 (field.name, library.column_for_field(field, accessor=field.name))
172 for field in extra_fields
173 if field.name not in self.get_table_class().base_columns
174 ]
176 return kwargs
178 def get_filterset_class(self):
179 filterset_modules = module_paths(
180 self.model, path="filtersets", suffix="FilterSet"
181 )
182 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
183 return filterset_factory(self.model, filterset_class)
185 def _get_columns_choices(self, columns_exclude):
186 # we start with the model fields
187 choices = [
188 (field.name, pretty_name(getattr(field, "verbose_name", field.name)))
189 for field in self.model._meta.get_fields()
190 if not getattr(field, "auto_created", False)
191 and not isinstance(field, ManyToManyRel)
192 ]
193 # we add any annotated fields to that
194 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
195 # now we drop all the choices that are listed in columns_exclude
196 choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
197 return choices
199 def _get_columns_initial(self, columns_exclude):
200 return [
201 field
202 for field in self.get_table().columns.names()
203 if field not in columns_exclude
204 ]
206 def get_filterset(self, filterset_class):
207 """
208 We override the `get_filterset` method, so we can inject a
209 `columns` selector into the form
210 """
211 filterset = super().get_filterset(filterset_class)
212 columns_exclude = filterset.form.columns_exclude
214 # we inject a `columns` selector in the beginning of the form
215 if choices := self._get_columns_choices(columns_exclude):
216 columns = forms.MultipleChoiceField(
217 required=False,
218 choices=choices,
219 initial=self._get_columns_initial(columns_exclude),
220 )
221 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
223 return filterset
225 def get_queryset(self):
226 queryset_methods = module_paths(
227 self.model, path="querysets", suffix="ListViewQueryset"
228 )
229 queryset = first_member_match(queryset_methods) or (lambda x: x)
230 return queryset(self.model.objects.all())
232 def get_table_pagination(self, table):
233 """
234 Override `get_table_pagination` from the tables2 TableMixinBase,
235 so we can set the paginate_by and the table_pagination value as attribute of the table.
236 """
237 self.paginate_by = getattr(table, "paginate_by", None)
238 self.table_pagination = getattr(table, "table_pagination", None)
239 return super().get_table_pagination(table)
242class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
243 """
244 Detail view for a generic model.
245 Access requires the `<model>_view` permission.
246 """
248 permission_action_required = "view"
251class Create(GenericModelMixin, PermissionRequiredMixin, CreateView):
252 """
253 Create view for a generic model.
254 Access requires the `<model>_add` permission.
255 The form class is overridden by the first match from
256 the `first_member_match` helper.
257 """
259 template_name = "generic/generic_form.html"
260 permission_action_required = "add"
262 def get_form_class(self):
263 form_modules = module_paths(self.model, path="forms", suffix="Form")
264 form_class = first_member_match(form_modules, GenericModelForm)
265 return modelform_factory(self.model, form_class)
267 def get_success_url(self):
268 return self.object.get_create_success_url()
271class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
272 """
273 Delete view for a generic model.
274 Access requires the `<model>_delete` permission.
275 """
277 permission_action_required = "delete"
279 def get_success_url(self):
280 return reverse(
281 "apis_core:generic:list",
282 args=[self.request.resolver_match.kwargs["contenttype"]],
283 )
285 def delete(self, *args, **kwargs):
286 if "HX-Request" in self.request.headers:
287 return (
288 reverse_lazy(
289 "apis_core:generic:list",
290 args=[self.request.resolver_match.kwargs["contenttype"]],
291 ),
292 )
293 return super().delete(*args, **kwargs)
296class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView):
297 """
298 Update view for a generic model.
299 Access requires the `<model>_change` permission.
300 The form class is overridden by the first match from
301 the `first_member_match` helper.
302 """
304 permission_action_required = "change"
306 def get_form_class(self):
307 form_modules = module_paths(self.model, path="forms", suffix="Form")
308 form_class = first_member_match(form_modules, GenericModelForm)
309 return modelform_factory(self.model, form_class)
311 def get_success_url(self):
312 return self.object.get_update_success_url()
315class Duplicate(GenericModelMixin, PermissionRequiredMixin, View):
316 permission_action_required = "add"
318 def get(self, request, *args, **kwargs):
319 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
320 newobj = source_obj.duplicate()
322 messages.success(
323 request,
324 format_html(
325 "<a href={}>{}</a> was successfully duplicated to the current object:",
326 source_obj.get_absolute_url(),
327 source_obj,
328 ),
329 )
330 return redirect(newobj.get_edit_url())
333class Autocomplete(
334 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
335):
336 """
337 Autocomplete view for a generic model.
338 Access requires the `<model>_view` permission.
339 The queryset is overridden by the first match from
340 the `first_member_match` helper.
341 """
343 permission_action_required = "view"
344 template_name_suffix = "_autocomplete_result"
346 def setup(self, *args, **kwargs):
347 super().setup(*args, **kwargs)
348 # We use a URI parameter to enable the create functionality in the
349 # autocomplete dropdown. It is not important what the value of the
350 # `create_field` is, because we use create_object_from_uri anyway.
351 self.create_field = self.request.GET.get("create", None)
352 try:
353 template = select_template(self.get_template_names())
354 self.template = template.template.name
355 except TemplateDoesNotExist:
356 self.template = None
358 def get_queryset(self):
359 queryset_methods = module_paths(
360 self.model, path="querysets", suffix="AutocompleteQueryset"
361 )
362 queryset = first_member_match(queryset_methods)
363 if queryset:
364 return queryset(self.model, self.q)
365 return self.model.objects.filter(generate_search_filter(self.model, self.q))
367 def get_results(self, context):
368 external_only = self.kwargs.get("external_only", False)
369 results = [] if external_only else super().get_results(context)
370 queryset_methods = module_paths(
371 self.model, path="querysets", suffix="ExternalAutocomplete"
372 )
373 ExternalAutocomplete = first_member_match(queryset_methods)
374 if ExternalAutocomplete:
375 results.extend(ExternalAutocomplete().get_results(self.q))
376 return results
378 def create_object(self, value):
379 """
380 We try multiple approaches to create a model instance from a value:
381 * we first test if the value is an URL and if so we expect it to be
382 something that can be imported using one of the configured importers
383 and so we pass the value to the import logic.
384 * if the value is not a string, we try to pass it to the `create_from_string`
385 method of the model, if that does exist. Its the models responsibility to
386 implement this method and the method should somehow know how to create
387 model instance from the value...
388 * finally we pass the value to the `create_object` method from the DAL
389 view, which tries to pass it to `get_or_create` which likely also fails,
390 but this is expected and we raise a more useful exception.
391 """
392 try:
393 URLValidator()(value)
394 return create_object_from_uri(
395 value, self.queryset.model, raise_on_fail=True
396 )
397 except ValidationError:
398 pass
399 try:
400 return self.queryset.model.create_from_string(value)
401 except AttributeError:
402 raise ImproperlyConfigured(
403 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
404 )
406 def post(self, request, *args, **kwargs):
407 try:
408 return super().post(request, *args, **kwargs)
409 except Exception as e:
410 return http.JsonResponse({"error": str(e)})
413class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
414 template_name = "generic/generic_import_form.html"
415 template_name_suffix = "_import"
416 permission_action_required = "add"
418 def get_form_class(self):
419 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
420 form_class = first_member_match(form_modules, GenericImportForm)
421 return modelform_factory(self.model, form_class)
423 def form_valid(self, form):
424 self.object = form.cleaned_data["url"]
425 return super().form_valid(form)
427 def get_success_url(self):
428 return self.object.get_absolute_url()
431class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView):
432 """
433 This view provides a simple form that allows to select other entities (also from
434 external sources, if set up) and on form submit redirects to the Enrich view.
435 """
437 template_name_suffix = "_selectmergeorenrich"
438 permission_action_required = "add"
439 form_class = GenericSelectMergeOrEnrichForm
441 def get_object(self, *args, **kwargs):
442 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
444 def get_context_data(self, *args, **kwargs):
445 context = super().get_context_data(*args, **kwargs)
446 context["object"] = self.get_object()
447 return context
449 def get_form_kwargs(self, *args, **kwargs):
450 kwargs = super().get_form_kwargs(*args, **kwargs)
451 kwargs["instance"] = self.get_object()
452 return kwargs
455class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView):
456 """
457 Generic merge view.
458 """
460 permission_action_required = "change"
461 form_class = GenericMergeWithForm
462 template_name = "generic/generic_merge.html"
464 def setup(self, *args, **kwargs):
465 super().setup(*args, **kwargs)
466 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
467 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
469 def get_context_data(self, **kwargs):
470 """
471 The context consists of the two objects that are merged as well
472 as a list of changes. Those changes are presented in the view as
473 a table with diffs
474 """
475 Change = namedtuple("Change", "field old new")
476 ctx = super().get_context_data(**kwargs)
477 ctx["changes"] = []
478 for field in self.object._meta.fields:
479 newval = self.object.get_field_value_after_merge(self.other, field)
480 ctx["changes"].append(
481 Change(field.verbose_name, getattr(self.object, field.name), newval)
482 )
483 ctx["object"] = self.object
484 ctx["other"] = self.other
485 return ctx
487 def form_valid(self, form):
488 self.object.merge_with([self.other])
489 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
490 return super().form_valid(form)
492 def get_success_url(self):
493 return self.object.get_absolute_url()
496class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView):
497 """
498 Enrich an entity with data from an external source
499 If so, it uses the proper Importer to get the data from the Uri and
500 provides the user with a form to select the fields that should be updated.
501 """
503 permission_action_required = "change"
504 template_name = "generic/generic_enrich.html"
505 form_class = GenericEnrichForm
506 importer_class = None
508 def setup(self, *args, **kwargs):
509 super().setup(*args, **kwargs)
510 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
511 self.uri = self.request.GET.get("uri")
512 if not self.uri:
513 messages.error(self.request, "No uri parameter specified.")
514 self.importer_class = get_importer_for_model(self.model)
516 def get(self, *args, **kwargs):
517 if self.uri.isdigit():
518 return redirect(self.object.get_merge_url(self.uri))
519 try:
520 uriobj = Uri.objects.get(uri=self.uri)
521 if uriobj.object_id != self.object.id:
522 messages.info(
523 self.request,
524 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
525 )
526 return redirect(self.object.get_merge_url(uriobj.object_id))
527 except Uri.DoesNotExist:
528 pass
529 return super().get(*args, **kwargs)
531 def get_context_data(self, **kwargs):
532 ctx = super().get_context_data(**kwargs)
533 ctx["object"] = self.object
534 ctx["uri"] = self.uri
535 return ctx
537 def get_form_kwargs(self, *args, **kwargs):
538 kwargs = super().get_form_kwargs(*args, **kwargs)
539 kwargs["instance"] = self.object
540 try:
541 importer = self.importer_class(self.uri, self.model)
542 kwargs["data"] = importer.get_data()
543 except ImproperlyConfigured as e:
544 messages.error(self.request, e)
545 return kwargs
547 def form_valid(self, form):
548 """
549 Go through all the form fields and extract the ones that
550 start with `update_` and that are set (those are the checkboxes that
551 select which fields to update).
552 Then use the importers `import_into_instance` method to set those
553 fields values on the model instance.
554 """
555 update_fields = [
556 key.removeprefix("update_")
557 for (key, value) in self.request.POST.items()
558 if key.startswith("update_") and value
559 ]
560 importer = self.importer_class(self.uri, self.model)
561 importer.import_into_instance(self.object, fields=update_fields)
562 messages.info(self.request, f"Updated fields {update_fields}")
563 content_type = ContentType.objects.get_for_model(self.model)
564 uri, created = Uri.objects.get_or_create(
565 uri=importer.get_uri,
566 content_type=content_type,
567 object_id=self.object.id,
568 )
569 if created:
570 messages.info(self.request, f"Added uri {self.uri} to {self.object}")
571 return super().form_valid(form)
573 def get_success_url(self):
574 return self.object.get_absolute_url()