Coverage for apis_core/generic/views.py: 37%
308 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-03 06:15 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-03 06:15 +0000
1from collections import namedtuple
2from copy import copy
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
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
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
54class Overview(TemplateView):
55 template_name = "generic/overview.html"
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 """
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()
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
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 []
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 """
124 template_name_suffix = "_list"
125 permission_action_required = "view"
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)
132 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
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}"
139 return super().get_export_filename(extension)
141 def get_table_kwargs(self):
142 kwargs = super().get_table_kwargs()
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 ]
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 ]
175 return kwargs
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)
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
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 ]
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
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 # If the filterset form contains form data
222 # we add a CSS class to the element wrapping
223 # that field in HTML. This CSS class can be
224 # used to emphasize the fields that are used.
225 # To be able to compare the fields with the form
226 # data, we create a temporary mapping between
227 # widget_names and fields
228 fields = {}
229 for name, field in filterset.form.fields.items():
230 fields[name] = name
231 if hasattr(field.widget, "widgets_names"):
232 for widget_name in field.widget.widgets_names:
233 fields[name + widget_name] = name
234 if data := filterset.form.data:
235 for param in [param for param, value in data.items() if value]:
236 if fieldname := fields.get(param, None):
237 filterset.form.helper[fieldname].wrap(
238 Field, wrapper_class="filter-input-selected"
239 )
241 return filterset
243 def get_queryset(self):
244 queryset_methods = module_paths(
245 self.model, path="querysets", suffix="ListViewQueryset"
246 )
247 queryset = first_member_match(queryset_methods) or (lambda x: x)
248 return queryset(self.model.objects.all())
250 def get_table_pagination(self, table):
251 """
252 Override `get_table_pagination` from the tables2 TableMixinBase,
253 so we can set the paginate_by and the table_pagination value as attribute of the table.
254 """
255 self.paginate_by = getattr(table, "paginate_by", None)
256 self.table_pagination = getattr(table, "table_pagination", None)
257 return super().get_table_pagination(table)
260class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
261 """
262 Detail view for a generic model.
263 Access requires the `<model>_view` permission.
264 """
266 permission_action_required = "view"
269class Create(
270 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
271):
272 """
273 Create view for a generic model.
274 Access requires the `<model>_add` permission.
275 The form class is overridden by the first match from
276 the `first_member_match` helper.
277 """
279 template_name_suffix = "_create"
280 permission_action_required = "add"
282 def get_form_class(self):
283 form_modules = module_paths(self.model, path="forms", suffix="Form")
284 form_class = first_member_match(form_modules, GenericModelForm)
285 return modelform_factory(self.model, form_class)
287 def get_success_message(self, cleaned_data):
288 message_templates = template_names_via_mro(
289 self.model, "_create_success_message.html"
290 )
291 template = select_template(message_templates)
292 return template.render({"object": self.object})
294 def get_success_url(self):
295 return self.object.get_create_success_url()
298class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
299 """
300 Delete view for a generic model.
301 Access requires the `<model>_delete` permission.
302 """
304 permission_action_required = "delete"
306 def get_success_url(self):
307 if redirect := self.request.GET.get("redirect"):
308 return redirect
309 return reverse(
310 "apis_core:generic:list",
311 args=[self.request.resolver_match.kwargs["contenttype"]],
312 )
315class Update(
316 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView
317):
318 """
319 Update view for a generic model.
320 Access requires the `<model>_change` permission.
321 The form class is overridden by the first match from
322 the `first_member_match` helper.
323 """
325 permission_action_required = "change"
327 def get_form_class(self):
328 form_modules = module_paths(self.model, path="forms", suffix="Form")
329 form_class = first_member_match(form_modules, GenericModelForm)
330 return modelform_factory(self.model, form_class)
332 def get_success_message(self, cleaned_data):
333 message_templates = template_names_via_mro(
334 self.model, "_update_success_message.html"
335 )
336 template = select_template(message_templates)
337 return template.render({"object": self.object})
339 def get_success_url(self):
340 return self.object.get_update_success_url()
343class Duplicate(GenericModelMixin, PermissionRequiredMixin, View):
344 permission_action_required = "add"
346 def get(self, request, *args, **kwargs):
347 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
348 newobj = source_obj.duplicate()
350 message_templates = template_names_via_mro(
351 self.model, "_duplicate_success_message.html"
352 )
353 template = select_template(message_templates)
354 messages.success(request, template.render({"object": source_obj}))
355 return redirect(newobj.get_edit_url())
358class Autocomplete(
359 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
360):
361 """
362 Autocomplete view for a generic model.
363 Access requires the `<model>_view` permission.
364 The queryset is overridden by the first match from
365 the `first_member_match` helper.
366 """
368 permission_action_required = "view"
369 template_name_suffix = "_autocomplete_result"
371 def setup(self, *args, **kwargs):
372 super().setup(*args, **kwargs)
373 # We use a URI parameter to enable the create functionality in the
374 # autocomplete dropdown. It is not important what the value of the
375 # `create_field` is, because we use create_object_from_uri anyway.
376 self.create_field = self.request.GET.get("create", None)
377 try:
378 template = select_template(self.get_template_names())
379 self.template = template.template.name
380 except TemplateDoesNotExist:
381 self.template = None
383 def get_queryset(self):
384 queryset_methods = module_paths(
385 self.model, path="querysets", suffix="AutocompleteQueryset"
386 )
387 queryset = first_member_match(queryset_methods)
388 if queryset:
389 return queryset(self.model, self.q)
390 return self.model.objects.filter(generate_search_filter(self.model, self.q))
392 def get_results(self, context):
393 external_only = self.kwargs.get("external_only", False)
394 results = [] if external_only else super().get_results(context)
395 queryset_methods = module_paths(
396 self.model, path="querysets", suffix="ExternalAutocomplete"
397 )
398 ExternalAutocomplete = first_member_match(queryset_methods)
399 if ExternalAutocomplete:
400 results.extend(ExternalAutocomplete().get_results(self.q))
401 return results
403 def create_object(self, value):
404 """
405 We try multiple approaches to create a model instance from a value:
406 * we first test if the value is an URL and if so we expect it to be
407 something that can be imported using one of the configured importers
408 and so we pass the value to the import logic.
409 * if the value is not a string, we try to pass it to the `create_from_string`
410 method of the model, if that does exist. Its the models responsibility to
411 implement this method and the method should somehow know how to create
412 model instance from the value...
413 * finally we pass the value to the `create_object` method from the DAL
414 view, which tries to pass it to `get_or_create` which likely also fails,
415 but this is expected and we raise a more useful exception.
416 """
417 try:
418 URLValidator()(value)
419 return create_object_from_uri(
420 value, self.queryset.model, raise_on_fail=True
421 )
422 except ValidationError:
423 pass
424 try:
425 return self.queryset.model.create_from_string(value)
426 except AttributeError:
427 raise ImproperlyConfigured(
428 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
429 )
431 def post(self, request, *args, **kwargs):
432 try:
433 return super().post(request, *args, **kwargs)
434 except Exception as e:
435 return http.JsonResponse({"error": str(e)})
438class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
439 template_name_suffix = "_import"
440 permission_action_required = "add"
442 def get_form_class(self):
443 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
444 form_class = first_member_match(form_modules, GenericImportForm)
445 return modelform_factory(self.model, form_class)
447 def form_valid(self, form):
448 self.object = form.cleaned_data["url"]
449 return super().form_valid(form)
451 def get_success_url(self):
452 return self.object.get_absolute_url()
455class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView):
456 """
457 This view provides a simple form that allows to select other entities (also from
458 external sources, if set up) and on form submit redirects to the Enrich view.
459 """
461 template_name_suffix = "_selectmergeorenrich"
462 permission_action_required = "add"
463 form_class = GenericSelectMergeOrEnrichForm
465 def get_object(self, *args, **kwargs):
466 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
468 def get_context_data(self, *args, **kwargs):
469 context = super().get_context_data(*args, **kwargs)
470 context["object"] = self.get_object()
471 return context
473 def get_form_kwargs(self, *args, **kwargs):
474 kwargs = super().get_form_kwargs(*args, **kwargs)
475 kwargs["instance"] = self.get_object()
476 return kwargs
479class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView):
480 """
481 Generic merge view.
482 """
484 permission_action_required = "change"
485 form_class = GenericMergeWithForm
486 template_name_suffix = "_merge"
488 def setup(self, *args, **kwargs):
489 super().setup(*args, **kwargs)
490 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
491 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
493 def get_context_data(self, **kwargs):
494 """
495 The context consists of the two objects that are merged as well
496 as a list of changes. Those changes are presented in the view as
497 a table with diffs
498 """
499 Change = namedtuple("Change", "field old new")
500 ctx = super().get_context_data(**kwargs)
501 ctx["changes"] = []
502 for field in self.object._meta.fields:
503 newval = self.object.get_field_value_after_merge(self.other, field)
504 ctx["changes"].append(
505 Change(field.verbose_name, getattr(self.object, field.name), newval)
506 )
507 ctx["object"] = self.object
508 ctx["other"] = self.other
509 return ctx
511 def form_valid(self, form):
512 self.object.merge_with([self.other])
513 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
514 return super().form_valid(form)
516 def get_success_url(self):
517 return self.object.get_absolute_url()
520class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView):
521 """
522 Enrich an entity with data from an external source
523 If so, it uses the proper Importer to get the data from the Uri and
524 provides the user with a form to select the fields that should be updated.
525 """
527 permission_action_required = "change"
528 template_name_suffix = "_enrich"
529 form_class = GenericEnrichForm
530 importer_class = None
532 def setup(self, *args, **kwargs):
533 super().setup(*args, **kwargs)
534 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
535 self.uri = self.request.GET.get("uri")
536 if not self.uri:
537 messages.error(self.request, "No uri parameter specified.")
538 self.importer_class = get_importer_for_model(self.model)
540 def get(self, *args, **kwargs):
541 if self.uri.isdigit():
542 return redirect(self.object.get_merge_url(self.uri))
543 try:
544 uriobj = Uri.objects.get(uri=self.uri)
545 if uriobj.object_id != self.object.id:
546 messages.info(
547 self.request,
548 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
549 )
550 return redirect(self.object.get_merge_url(uriobj.object_id))
551 except Uri.DoesNotExist:
552 pass
553 return super().get(*args, **kwargs)
555 def get_context_data(self, **kwargs):
556 ctx = super().get_context_data(**kwargs)
557 ctx["object"] = self.object
558 ctx["uri"] = self.uri
559 return ctx
561 def get_form_kwargs(self, *args, **kwargs):
562 kwargs = super().get_form_kwargs(*args, **kwargs)
563 kwargs["instance"] = self.object
564 try:
565 importer = self.importer_class(self.uri, self.model)
566 kwargs["data"] = importer.get_data()
567 except ImproperlyConfigured as e:
568 messages.error(self.request, e)
569 return kwargs
571 def form_valid(self, form):
572 """
573 Go through all the form fields and extract the ones that
574 start with `update_` and that are set (those are the checkboxes that
575 select which fields to update).
576 Then use the importers `import_into_instance` method to set those
577 fields values on the model instance.
578 """
579 update_fields = [
580 key.removeprefix("update_")
581 for (key, value) in self.request.POST.items()
582 if key.startswith("update_") and value
583 ]
584 importer = self.importer_class(self.uri, self.model)
585 importer.import_into_instance(self.object, fields=update_fields)
586 messages.info(self.request, f"Updated fields {update_fields}")
587 content_type = ContentType.objects.get_for_model(self.model)
588 uri, created = Uri.objects.get_or_create(
589 uri=importer.get_uri,
590 content_type=content_type,
591 object_id=self.object.id,
592 )
593 if created:
594 messages.info(self.request, f"Added uri {self.uri} to {self.object}")
595 return super().form_valid(form)
597 def get_success_url(self):
598 return self.object.get_absolute_url()