Coverage for apis_core/generic/views.py: 40%
242 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-22 07:51 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-22 07:51 +0000
1from collections import namedtuple
2from copy import copy
3from typing import Optional
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.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.tables import table_factory
26from apis_core.apis_metainfo.models import Uri
27from apis_core.core.mixins import ListViewObjectFilterMixin
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 GenericMergeForm,
35 GenericModelForm,
36)
37from .helpers import (
38 first_member_match,
39 generate_search_filter,
40 module_paths,
41 permission_fullname,
42 template_names_via_mro,
43)
44from .tables import GenericTable
47class Overview(TemplateView):
48 template_name = "generic/overview.html"
51class GenericModelMixin:
52 """
53 A mixin providing the common functionality for all the views working
54 with `generic` models - that is models that are accessed via the
55 contenttype framework (using `app_label.model`).
56 It sets the `.model` of the view and generates a list of possible template
57 names (based on the MRO of the model).
58 If the view has a `permission_action_required` attribute, this is used
59 to set the permission required to access the view for this specific model.
60 """
62 def setup(self, *args, **kwargs):
63 super().setup(*args, **kwargs)
64 if contenttype := kwargs.get("contenttype"):
65 self.model = contenttype.model_class()
66 self.queryset = self.model.objects.all()
68 def get_template_names(self):
69 template_names = []
70 if hasattr(super(), "get_template_names"):
71 template_names = super().get_template_names()
72 suffix = ".html"
73 if hasattr(self, "template_name_suffix"):
74 suffix = self.template_name_suffix + ".html"
75 additional_templates = template_names_via_mro(self.model, suffix) + [
76 f"generic/generic{suffix}"
77 ]
78 template_names += filter(
79 lambda template: template not in template_names, additional_templates
80 )
81 return template_names
83 def get_permission_required(self):
84 if hasattr(settings, "APIS_VIEW_PASSES_TEST"):
85 if settings.APIS_VIEW_PASSES_TEST(self):
86 return []
87 if hasattr(self, "permission_action_required"):
88 return [permission_fullname(self.permission_action_required, self.model)]
89 return []
92class List(
93 ListViewObjectFilterMixin,
94 GenericModelMixin,
95 PermissionRequiredMixin,
96 SingleTableMixin,
97 FilterView,
98):
99 """
100 List view for a generic model.
101 Access requires the `<model>_view` permission.
102 It is based on django-filters FilterView and django-tables SingleTableMixin.
103 The table class is overridden by the first match from
104 the `first_member_match` helper.
105 The filterset class is overridden by the first match from
106 the `first_member_match` helper.
107 The queryset is overridden by the first match from
108 the `first_member_match` helper.
109 """
111 template_name_suffix = "_list"
112 permission_action_required = "view"
114 def get_table_class(self):
115 table_modules = module_paths(self.model, path="tables", suffix="Table")
116 table_class = first_member_match(table_modules, GenericTable)
117 return table_factory(self.model, table_class)
119 def get_table_kwargs(self):
120 kwargs = super().get_table_kwargs()
122 # we look at the selected columns and exclude
123 # all modelfields that are not part of that list
124 selected_columns = self.request.GET.getlist(
125 "columns",
126 self.get_filterset(self.get_filterset_class()).form["columns"].initial,
127 )
128 modelfields = self.model._meta.get_fields()
129 kwargs["exclude"] = [
130 field.name for field in modelfields if field.name not in selected_columns
131 ]
133 # now we look at the selected columns and
134 # add all modelfields and annotated fields that
135 # are part of the selected columns to the extra_columns
136 annotationfields = list()
137 for key, value in self.object_list.query.annotations.items():
138 # we have to use copy, so we don't edit the original field
139 fake_field = copy(getattr(value, "field", value.output_field))
140 setattr(fake_field, "name", key)
141 annotationfields.append(fake_field)
142 extra_fields = list(
143 filter(
144 lambda x: x.name in selected_columns,
145 modelfields + tuple(annotationfields),
146 )
147 )
148 kwargs["extra_columns"] = [
149 (field.name, library.column_for_field(field, accessor=field.name))
150 for field in extra_fields
151 if field.name not in self.get_table_class().base_columns
152 ]
154 return kwargs
156 def get_filterset_class(self):
157 filterset_modules = module_paths(
158 self.model, path="filtersets", suffix="FilterSet"
159 )
160 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
161 return filterset_factory(self.model, filterset_class)
163 def _get_columns_choices(self, columns_exclude):
164 # we start with the model fields
165 choices = [
166 (field.name, pretty_name(getattr(field, "verbose_name", field.name)))
167 for field in self.model._meta.get_fields()
168 ]
169 # we add any annotated fields to that
170 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
171 # now we drop all the choices that are listed in columns_exclude
172 choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
173 return choices
175 def _get_columns_initial(self, columns_exclude):
176 return [
177 field
178 for field in self.get_table().columns.names()
179 if field not in columns_exclude
180 ]
182 def get_filterset(self, filterset_class):
183 """
184 We override the `get_filterset` method, so we can inject a
185 `columns` selector into the form
186 """
187 filterset = super().get_filterset(filterset_class)
188 columns_exclude = filterset.form.columns_exclude
190 # we inject a `columns` selector in the beginning of the form
191 columns = forms.MultipleChoiceField(
192 required=False,
193 choices=self._get_columns_choices(columns_exclude),
194 initial=self._get_columns_initial(columns_exclude),
195 )
196 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
198 return filterset
200 def get_queryset(self):
201 queryset_methods = module_paths(
202 self.model, path="querysets", suffix="ListViewQueryset"
203 )
204 queryset = first_member_match(queryset_methods) or (lambda x: x)
205 return self.filter_queryset(queryset(self.model.objects.all()))
207 def get_paginate_by(self, table_data) -> Optional[int]:
208 """
209 Override `get_paginate_by` from the tables2 TableMixinBase,
210 so we can set the paginate_by value as attribute of the table.
211 """
212 return getattr(self.get_table_class(), "paginate_by", None)
215class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
216 """
217 Detail view for a generic model.
218 Access requires the `<model>_view` permission.
219 """
221 permission_action_required = "view"
224class Create(GenericModelMixin, PermissionRequiredMixin, CreateView):
225 """
226 Create view for a generic model.
227 Access requires the `<model>_add` permission.
228 The form class is overridden by the first match from
229 the `first_member_match` helper.
230 """
232 template_name = "generic/generic_form.html"
233 permission_action_required = "add"
235 def get_form_class(self):
236 form_modules = module_paths(self.model, path="forms", suffix="Form")
237 form_class = first_member_match(form_modules, GenericModelForm)
238 return modelform_factory(self.model, form_class)
240 def get_success_url(self):
241 return self.object.get_create_success_url()
244class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
245 """
246 Delete view for a generic model.
247 Access requires the `<model>_delete` permission.
248 """
250 permission_action_required = "delete"
252 def get_success_url(self):
253 return reverse(
254 "apis_core:generic:list",
255 args=[self.request.resolver_match.kwargs["contenttype"]],
256 )
258 def delete(self, *args, **kwargs):
259 if "HX-Request" in self.request.headers:
260 return (
261 reverse_lazy(
262 "apis_core:generic:list",
263 args=[self.request.resolver_match.kwargs["contenttype"]],
264 ),
265 )
266 return super().delete(*args, **kwargs)
269class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView):
270 """
271 Update view for a generic model.
272 Access requires the `<model>_change` permission.
273 The form class is overridden by the first match from
274 the `first_member_match` helper.
275 """
277 permission_action_required = "change"
279 def get_form_class(self):
280 form_modules = module_paths(self.model, path="forms", suffix="Form")
281 form_class = first_member_match(form_modules, GenericModelForm)
282 return modelform_factory(self.model, form_class)
284 def get_success_url(self):
285 return self.object.get_update_success_url()
288class Autocomplete(
289 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
290):
291 """
292 Autocomplete view for a generic model.
293 Access requires the `<model>_view` permission.
294 The queryset is overridden by the first match from
295 the `first_member_match` helper.
296 """
298 permission_action_required = "view"
299 template_name_suffix = "_autocomplete_result"
301 def setup(self, *args, **kwargs):
302 super().setup(*args, **kwargs)
303 # We use a URI parameter to enable the create functionality in the
304 # autocomplete dropdown. It is not important what the value of the
305 # `create_field` is, because we use create_object_from_uri anyway.
306 self.create_field = self.request.GET.get("create", None)
307 try:
308 template = select_template(self.get_template_names())
309 self.template = template.template.name
310 except TemplateDoesNotExist:
311 self.template = None
313 def get_queryset(self):
314 queryset_methods = module_paths(
315 self.model, path="querysets", suffix="AutocompleteQueryset"
316 )
317 queryset = first_member_match(queryset_methods)
318 if queryset:
319 return queryset(self.model, self.q)
320 return self.model.objects.filter(generate_search_filter(self.model, self.q))
322 def get_results(self, context):
323 external_only = self.kwargs.get("external_only", False)
324 results = [] if external_only else super().get_results(context)
325 queryset_methods = module_paths(
326 self.model, path="querysets", suffix="ExternalAutocomplete"
327 )
328 ExternalAutocomplete = first_member_match(queryset_methods)
329 if ExternalAutocomplete:
330 results.extend(ExternalAutocomplete().get_results(self.q))
331 return results
333 def create_object(self, value):
334 return create_object_from_uri(value, self.queryset.model, raise_on_fail=True)
336 def post(self, request, *args, **kwargs):
337 try:
338 return super().post(request, *args, **kwargs)
339 except Exception as e:
340 return http.JsonResponse({"error": str(e)})
343class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
344 template_name = "generic/generic_import_form.html"
345 template_name_suffix = "_import"
346 permission_action_required = "add"
348 def get_form_class(self):
349 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
350 form_class = first_member_match(form_modules, GenericImportForm)
351 return modelform_factory(self.model, form_class)
353 def form_valid(self, form):
354 self.object = form.cleaned_data["url"]
355 return super().form_valid(form)
357 def get_success_url(self):
358 return self.object.get_absolute_url()
361class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView):
362 """
363 Generic merge view.
364 """
366 permission_action_required = "change"
367 form_class = GenericMergeForm
368 template_name = "generic/generic_merge.html"
370 def setup(self, *args, **kwargs):
371 super().setup(*args, **kwargs)
372 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
373 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
375 def get_context_data(self, **kwargs):
376 """
377 The context consists of the two objects that are merged as well
378 as a list of changes. Those changes are presented in the view as
379 a table with diffs
380 """
381 Change = namedtuple("Change", "field old new")
382 ctx = super().get_context_data(**kwargs)
383 ctx["changes"] = []
384 for field in self.object._meta.fields:
385 newval = self.object.get_field_value_after_merge(self.other, field)
386 ctx["changes"].append(
387 Change(field.verbose_name, getattr(self.object, field.name), newval)
388 )
389 ctx["object"] = self.object
390 ctx["other"] = self.other
391 return ctx
393 def form_valid(self, form):
394 self.object.merge_with([self.other])
395 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
396 return super().form_valid(form)
398 def get_success_url(self):
399 return self.object.get_absolute_url()
402class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView):
403 """
404 Enrich an entity with data from an external source
405 If so, it uses the proper Importer to get the data from the Uri and
406 provides the user with a form to select the fields that should be updated.
407 """
409 permission_action_required = "change"
410 template_name = "generic/generic_enrich.html"
411 form_class = GenericEnrichForm
412 importer_class = None
414 def setup(self, *args, **kwargs):
415 super().setup(*args, **kwargs)
416 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
417 self.uri = self.request.GET.get("uri")
418 if not self.uri:
419 messages.error(self.request, "No uri parameter specified.")
420 self.importer_class = get_importer_for_model(self.model)
422 def get(self, *args, **kwargs):
423 if self.uri.isdigit():
424 return redirect(self.object.get_merge_url(self.uri))
425 try:
426 uriobj = Uri.objects.get(uri=self.uri)
427 if uriobj.root_object.id != self.object.id:
428 messages.info(
429 self.request,
430 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
431 )
432 return redirect(self.object.get_merge_url(uriobj.root_object.id))
433 except Uri.DoesNotExist:
434 pass
435 return super().get(*args, **kwargs)
437 def get_context_data(self, **kwargs):
438 ctx = super().get_context_data(**kwargs)
439 ctx["object"] = self.object
440 ctx["uri"] = self.uri
441 return ctx
443 def get_form_kwargs(self, *args, **kwargs):
444 kwargs = super().get_form_kwargs(*args, **kwargs)
445 kwargs["instance"] = self.object
446 try:
447 importer = self.importer_class(self.uri, self.model)
448 kwargs["data"] = importer.get_data()
449 except ImproperlyConfigured as e:
450 messages.error(self.request, e)
451 return kwargs
453 def form_valid(self, form):
454 """
455 Go through all the form fields and extract the ones that
456 start with `update_` and that are set (those are the checkboxes that
457 select which fields to update).
458 Then use the importers `import_into_instance` method to set those
459 fields values on the model instance.
460 """
461 update_fields = [
462 key.removeprefix("update_")
463 for (key, value) in self.request.POST.items()
464 if key.startswith("update_") and value
465 ]
466 importer = self.importer_class(self.uri, self.model)
467 importer.import_into_instance(self.object, fields=update_fields)
468 messages.info(self.request, f"Updated fields {update_fields}")
469 uri, created = Uri.objects.get_or_create(uri=self.uri, root_object=self.object)
470 if created:
471 messages.info(self.request, f"Added uri {self.uri} to {self.object}")
472 return super().form_valid(form)
474 def get_success_url(self):
475 return self.object.get_absolute_url()