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