Coverage for apis_core/generic/views.py: 40%
263 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-19 16:54 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-19 16:54 +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
11from django.forms import modelform_factory
12from django.forms.utils import pretty_name
13from django.shortcuts import get_object_or_404, redirect
14from django.template.exceptions import TemplateDoesNotExist
15from django.template.loader import select_template
16from django.urls import reverse, reverse_lazy
17from django.views.generic import DetailView
18from django.views.generic.base import TemplateView
19from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
20from django_filters.filterset import filterset_factory
21from django_filters.views import FilterView
22from django_tables2 import SingleTableMixin
23from django_tables2.columns import library
24from django_tables2.export.views import ExportMixin
25from django_tables2.tables import table_factory
27from apis_core.apis_metainfo.models import Uri
28from apis_core.utils.helpers import create_object_from_uri, get_importer_for_model
30from .filtersets import GenericFilterSet
31from .forms import (
32 GenericEnrichForm,
33 GenericImportForm,
34 GenericMergeWithForm,
35 GenericModelForm,
36 GenericSelectMergeOrEnrichForm,
37)
38from .helpers import (
39 first_member_match,
40 generate_search_filter,
41 module_paths,
42 permission_fullname,
43 template_names_via_mro,
44)
45from .tables import GenericTable
48class Overview(TemplateView):
49 template_name = "generic/overview.html"
52class GenericModelMixin:
53 """
54 A mixin providing the common functionality for all the views working
55 with `generic` models - that is models that are accessed via the
56 contenttype framework (using `app_label.model`).
57 It sets the `.model` of the view and generates a list of possible template
58 names (based on the MRO of the model).
59 If the view has a `permission_action_required` attribute, this is used
60 to set the permission required to access the view for this specific model.
61 """
63 def setup(self, *args, **kwargs):
64 super().setup(*args, **kwargs)
65 if contenttype := kwargs.get("contenttype"):
66 self.model = contenttype.model_class()
67 self.queryset = self.model.objects.all()
69 def get_template_names(self):
70 template_names = []
71 if hasattr(super(), "get_template_names"):
72 # Some parent classes come with custom template_names,
73 # some need a `.template_name` attribute set. For the
74 # latter ones we handle the missing `.template_name`
75 # gracefully
76 try:
77 template_names = super().get_template_names()
78 except ImproperlyConfigured:
79 pass
80 suffix = ".html"
81 if hasattr(self, "template_name_suffix"):
82 suffix = self.template_name_suffix + ".html"
83 additional_templates = template_names_via_mro(self.model, suffix) + [
84 f"generic/generic{suffix}"
85 ]
86 template_names += filter(
87 lambda template: template not in template_names, additional_templates
88 )
89 return template_names
91 def get_permission_required(self):
92 if getattr(self, "permission_action_required", None) == "view" and getattr(
93 settings, "APIS_ANON_VIEWS_ALLOWED", False
94 ):
95 return []
96 if hasattr(self, "permission_action_required"):
97 return [permission_fullname(self.permission_action_required, self.model)]
98 return []
101class List(
102 GenericModelMixin,
103 PermissionRequiredMixin,
104 ExportMixin,
105 SingleTableMixin,
106 FilterView,
107):
108 """
109 List view for a generic model.
110 Access requires the `<model>_view` permission.
111 It is based on django-filters FilterView and django-tables SingleTableMixin.
112 The table class is overridden by the first match from
113 the `first_member_match` helper.
114 The filterset class is overridden by the first match from
115 the `first_member_match` helper.
116 The queryset is overridden by the first match from
117 the `first_member_match` helper.
118 """
120 template_name_suffix = "_list"
121 permission_action_required = "view"
123 def get_table_class(self):
124 table_modules = module_paths(self.model, path="tables", suffix="Table")
125 table_class = first_member_match(table_modules, GenericTable)
126 return table_factory(self.model, table_class)
128 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
130 def get_export_filename(self, extension):
131 table_class = self.get_table_class()
132 if hasattr(table_class, "export_filename"):
133 return f"{table_class.export_filename}.{extension}"
135 return super().get_export_filename(extension)
137 def get_table_kwargs(self):
138 kwargs = super().get_table_kwargs()
140 # we look at the selected columns and exclude
141 # all modelfields that are not part of that list
142 selected_columns = self.request.GET.getlist(
143 "columns",
144 self.get_filterset(self.get_filterset_class()).form["columns"].initial,
145 )
146 modelfields = self.model._meta.get_fields()
147 kwargs["exclude"] = [
148 field.name for field in modelfields if field.name not in selected_columns
149 ]
151 # now we look at the selected columns and
152 # add all modelfields and annotated fields that
153 # are part of the selected columns to the extra_columns
154 annotationfields = list()
155 for key, value in self.object_list.query.annotations.items():
156 # we have to use copy, so we don't edit the original field
157 fake_field = copy(getattr(value, "field", value.output_field))
158 setattr(fake_field, "name", key)
159 annotationfields.append(fake_field)
160 extra_fields = list(
161 filter(
162 lambda x: x.name in selected_columns,
163 modelfields + tuple(annotationfields),
164 )
165 )
166 kwargs["extra_columns"] = [
167 (field.name, library.column_for_field(field, accessor=field.name))
168 for field in extra_fields
169 if field.name not in self.get_table_class().base_columns
170 ]
172 return kwargs
174 def get_filterset_class(self):
175 filterset_modules = module_paths(
176 self.model, path="filtersets", suffix="FilterSet"
177 )
178 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
179 return filterset_factory(self.model, filterset_class)
181 def _get_columns_choices(self, columns_exclude):
182 # we start with the model fields
183 choices = [
184 (field.name, pretty_name(getattr(field, "verbose_name", field.name)))
185 for field in self.model._meta.get_fields()
186 if field.name not in getattr(self.get_queryset(), "subclasses", [])
187 ]
188 # we add any annotated fields to that
189 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
190 # now we drop all the choices that are listed in columns_exclude
191 choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
192 return choices
194 def _get_columns_initial(self, columns_exclude):
195 return [
196 field
197 for field in self.get_table().columns.names()
198 if field not in columns_exclude
199 ]
201 def get_filterset(self, filterset_class):
202 """
203 We override the `get_filterset` method, so we can inject a
204 `columns` selector into the form
205 """
206 filterset = super().get_filterset(filterset_class)
207 columns_exclude = filterset.form.columns_exclude
209 # we inject a `columns` selector in the beginning of the form
210 columns = forms.MultipleChoiceField(
211 required=False,
212 choices=self._get_columns_choices(columns_exclude),
213 initial=self._get_columns_initial(columns_exclude),
214 )
215 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
217 return filterset
219 def get_table_pagination(self, table):
220 """
221 Override `get_table_pagination` from the tables2 TableMixinBase,
222 so we can set the paginate_by and the table_pagination value as attribute of the table.
223 """
224 self.paginate_by = getattr(table, "paginate_by", None)
225 self.table_pagination = getattr(table, "table_pagination", None)
226 return super().get_table_pagination(table)
229class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
230 """
231 Detail view for a generic model.
232 Access requires the `<model>_view` permission.
233 """
235 permission_action_required = "view"
238class Create(GenericModelMixin, PermissionRequiredMixin, CreateView):
239 """
240 Create view for a generic model.
241 Access requires the `<model>_add` permission.
242 The form class is overridden by the first match from
243 the `first_member_match` helper.
244 """
246 template_name = "generic/generic_form.html"
247 permission_action_required = "add"
249 def get_form_class(self):
250 form_modules = module_paths(self.model, path="forms", suffix="Form")
251 form_class = first_member_match(form_modules, GenericModelForm)
252 return modelform_factory(self.model, form_class)
254 def get_success_url(self):
255 return self.object.get_create_success_url()
258class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
259 """
260 Delete view for a generic model.
261 Access requires the `<model>_delete` permission.
262 """
264 permission_action_required = "delete"
266 def get_success_url(self):
267 return reverse(
268 "apis_core:generic:list",
269 args=[self.request.resolver_match.kwargs["contenttype"]],
270 )
272 def delete(self, *args, **kwargs):
273 if "HX-Request" in self.request.headers:
274 return (
275 reverse_lazy(
276 "apis_core:generic:list",
277 args=[self.request.resolver_match.kwargs["contenttype"]],
278 ),
279 )
280 return super().delete(*args, **kwargs)
283class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView):
284 """
285 Update view for a generic model.
286 Access requires the `<model>_change` permission.
287 The form class is overridden by the first match from
288 the `first_member_match` helper.
289 """
291 permission_action_required = "change"
293 def get_form_class(self):
294 form_modules = module_paths(self.model, path="forms", suffix="Form")
295 form_class = first_member_match(form_modules, GenericModelForm)
296 return modelform_factory(self.model, form_class)
298 def get_success_url(self):
299 return self.object.get_update_success_url()
302class Autocomplete(
303 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
304):
305 """
306 Autocomplete view for a generic model.
307 Access requires the `<model>_view` permission.
308 The queryset is overridden by the first match from
309 the `first_member_match` helper.
310 """
312 permission_action_required = "view"
313 template_name_suffix = "_autocomplete_result"
315 def setup(self, *args, **kwargs):
316 super().setup(*args, **kwargs)
317 # We use a URI parameter to enable the create functionality in the
318 # autocomplete dropdown. It is not important what the value of the
319 # `create_field` is, because we use create_object_from_uri anyway.
320 self.create_field = self.request.GET.get("create", None)
321 try:
322 template = select_template(self.get_template_names())
323 self.template = template.template.name
324 except TemplateDoesNotExist:
325 self.template = None
327 def get_queryset(self):
328 queryset_methods = module_paths(
329 self.model, path="querysets", suffix="AutocompleteQueryset"
330 )
331 queryset = first_member_match(queryset_methods)
332 if queryset:
333 return queryset(self.model, self.q)
334 return self.model.objects.filter(generate_search_filter(self.model, self.q))
336 def get_results(self, context):
337 external_only = self.kwargs.get("external_only", False)
338 results = [] if external_only else super().get_results(context)
339 queryset_methods = module_paths(
340 self.model, path="querysets", suffix="ExternalAutocomplete"
341 )
342 ExternalAutocomplete = first_member_match(queryset_methods)
343 if ExternalAutocomplete:
344 results.extend(ExternalAutocomplete().get_results(self.q))
345 return results
347 def create_object(self, value):
348 return create_object_from_uri(value, self.queryset.model, raise_on_fail=True)
350 def post(self, request, *args, **kwargs):
351 try:
352 return super().post(request, *args, **kwargs)
353 except Exception as e:
354 return http.JsonResponse({"error": str(e)})
357class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
358 template_name = "generic/generic_import_form.html"
359 template_name_suffix = "_import"
360 permission_action_required = "add"
362 def get_form_class(self):
363 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
364 form_class = first_member_match(form_modules, GenericImportForm)
365 return modelform_factory(self.model, form_class)
367 def form_valid(self, form):
368 self.object = form.cleaned_data["url"]
369 return super().form_valid(form)
371 def get_success_url(self):
372 return self.object.get_absolute_url()
375class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView):
376 """
377 This view provides a simple form that allows to select other entities (also from
378 external sources, if set up) and on form submit redirects to the Enrich view.
379 """
381 template_name_suffix = "_selectmergeorenrich"
382 permission_action_required = "create"
383 form_class = GenericSelectMergeOrEnrichForm
385 def get_object(self, *args, **kwargs):
386 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
388 def get_context_data(self, *args, **kwargs):
389 context = super().get_context_data(*args, **kwargs)
390 context["object"] = self.get_object()
391 return context
393 def get_form_kwargs(self, *args, **kwargs):
394 kwargs = super().get_form_kwargs(*args, **kwargs)
395 kwargs["instance"] = self.get_object()
396 return kwargs
399class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView):
400 """
401 Generic merge view.
402 """
404 permission_action_required = "change"
405 form_class = GenericMergeWithForm
406 template_name = "generic/generic_merge.html"
408 def setup(self, *args, **kwargs):
409 super().setup(*args, **kwargs)
410 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
411 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
413 def get_context_data(self, **kwargs):
414 """
415 The context consists of the two objects that are merged as well
416 as a list of changes. Those changes are presented in the view as
417 a table with diffs
418 """
419 Change = namedtuple("Change", "field old new")
420 ctx = super().get_context_data(**kwargs)
421 ctx["changes"] = []
422 for field in self.object._meta.fields:
423 newval = self.object.get_field_value_after_merge(self.other, field)
424 ctx["changes"].append(
425 Change(field.verbose_name, getattr(self.object, field.name), newval)
426 )
427 ctx["object"] = self.object
428 ctx["other"] = self.other
429 return ctx
431 def form_valid(self, form):
432 self.object.merge_with([self.other])
433 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
434 return super().form_valid(form)
436 def get_success_url(self):
437 return self.object.get_absolute_url()
440class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView):
441 """
442 Enrich an entity with data from an external source
443 If so, it uses the proper Importer to get the data from the Uri and
444 provides the user with a form to select the fields that should be updated.
445 """
447 permission_action_required = "change"
448 template_name = "generic/generic_enrich.html"
449 form_class = GenericEnrichForm
450 importer_class = None
452 def setup(self, *args, **kwargs):
453 super().setup(*args, **kwargs)
454 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
455 self.uri = self.request.GET.get("uri")
456 if not self.uri:
457 messages.error(self.request, "No uri parameter specified.")
458 self.importer_class = get_importer_for_model(self.model)
460 def get(self, *args, **kwargs):
461 if self.uri.isdigit():
462 return redirect(self.object.get_merge_url(self.uri))
463 try:
464 uriobj = Uri.objects.get(uri=self.uri)
465 if uriobj.object_id != self.object.id:
466 messages.info(
467 self.request,
468 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
469 )
470 return redirect(self.object.get_merge_url(uriobj.object_id))
471 except Uri.DoesNotExist:
472 pass
473 return super().get(*args, **kwargs)
475 def get_context_data(self, **kwargs):
476 ctx = super().get_context_data(**kwargs)
477 ctx["object"] = self.object
478 ctx["uri"] = self.uri
479 return ctx
481 def get_form_kwargs(self, *args, **kwargs):
482 kwargs = super().get_form_kwargs(*args, **kwargs)
483 kwargs["instance"] = self.object
484 try:
485 importer = self.importer_class(self.uri, self.model)
486 kwargs["data"] = importer.get_data()
487 except ImproperlyConfigured as e:
488 messages.error(self.request, e)
489 return kwargs
491 def form_valid(self, form):
492 """
493 Go through all the form fields and extract the ones that
494 start with `update_` and that are set (those are the checkboxes that
495 select which fields to update).
496 Then use the importers `import_into_instance` method to set those
497 fields values on the model instance.
498 """
499 update_fields = [
500 key.removeprefix("update_")
501 for (key, value) in self.request.POST.items()
502 if key.startswith("update_") and value
503 ]
504 importer = self.importer_class(self.uri, self.model)
505 importer.import_into_instance(self.object, fields=update_fields)
506 messages.info(self.request, f"Updated fields {update_fields}")
507 content_type = ContentType.objects.get_for_model(self.model)
508 uri, created = Uri.objects.get_or_create(
509 uri=self.uri,
510 content_type=content_type,
511 object_id=self.object.id,
512 )
513 if created:
514 messages.info(self.request, f"Added uri {self.uri} to {self.object}")
515 return super().form_valid(form)
517 def get_success_url(self):
518 return self.object.get_absolute_url()