Coverage for apis_core/generic/views.py: 81%
352 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-14 10:55 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-14 10:55 +0000
1import logging
2import traceback
3from collections import namedtuple
4from copy import copy
6from crispy_forms.layout import Field
7from dal import autocomplete
8from django import http
9from django.conf import settings
10from django.contrib import messages
11from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
12from django.contrib.contenttypes.models import ContentType
13from django.contrib.messages.views import SuccessMessageMixin
14from django.core.exceptions import ImproperlyConfigured, ValidationError
15from django.core.validators import URLValidator
16from django.db import transaction
17from django.db.models.fields.related import ManyToManyRel
18from django.forms import modelform_factory
19from django.forms.utils import pretty_name
20from django.http import QueryDict
21from django.shortcuts import get_object_or_404, redirect
22from django.template.exceptions import TemplateDoesNotExist
23from django.template.loader import select_template
24from django.utils.text import capfirst
25from django.views import View
26from django.views.generic import DetailView
27from django.views.generic.base import TemplateView
28from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
29from django_filters.filterset import filterset_factory
30from django_filters.views import FilterView
31from django_tables2 import SingleTableMixin
32from django_tables2.columns import library
33from django_tables2.export.views import ExportMixin
34from django_tables2.tables import table_factory
36from apis_core.generic.utils import get_autocomplete_data_and_normalized_uri
37from apis_core.uris.models import Uri
39from .filtersets import GenericFilterSet
40from .forms import (
41 ColumnsSelectorForm,
42 GenericEnrichForm,
43 GenericImportForm,
44 GenericMergeWithForm,
45 GenericModelForm,
46 GenericSelectMergeOrEnrichForm,
47)
48from .helpers import (
49 first_member_match,
50 generate_search_filter,
51 module_paths,
52 permission_fullname,
53 template_names_via_mro,
54)
55from .tables import GenericTable
57logger = logging.getLogger(__name__)
60class Overview(LoginRequiredMixin, TemplateView):
61 template_name = "generic/overview.html"
64class GenericModelPermissionRequiredMixin(PermissionRequiredMixin):
65 """
66 Verify that the current user has the required permission for this model.
67 The model overrides the `PermissionRequiredMixin.get_permission_required`
68 method to generate the required permission name on the fly, based on a
69 verb (`permission_action_required`) and the model this view act upon.
70 This allows us to set `permission_action_required` simply to `add`, or
71 `view` and reuse the mixin for views that work with different models.
72 In addition, for the views that have `permission_action_required` set to
73 `view`, it check if there is the global setting `APIS_ANON_VIEWS_ALLOWED`
74 set to `True`, which permits anonymouse users access to the view.
75 """
77 def get_permission_required(self):
78 if not hasattr(self, "model"):
79 raise ImproperlyConfigured(
80 f"{self.__class__.__name__} is missing the model attribute"
81 )
82 if getattr(self, "permission_action_required", None) == "view" and getattr(
83 settings, "APIS_ANON_VIEWS_ALLOWED", False
84 ):
85 return []
86 if hasattr(self, "permission_action_required"):
87 return [permission_fullname(self.permission_action_required, self.model)]
88 return []
91class GenericModelMixin:
92 """
93 A mixin providing the common functionality for all the views working
94 with `generic` models - that is models that are accessed via the
95 contenttype framework (using `app_label.model`).
96 It sets the `.model` of the view and generates a list of possible template
97 names (based on the MRO of the model).
98 If the view has a `permission_action_required` attribute, this is used
99 to set the permission required to access the view for this specific model.
100 """
102 def setup(self, *args, **kwargs):
103 super().setup(*args, **kwargs)
104 if contenttype := kwargs.get("contenttype"):
105 self.model = contenttype.model_class()
106 self.queryset = self.model.objects.all()
108 def get_template_names(self):
109 template_names = []
110 if hasattr(super(), "get_template_names"):
111 # Some parent classes come with custom template_names,
112 # some need a `.template_name` attribute set. For the
113 # latter ones we handle the missing `.template_name`
114 # gracefully
115 try:
116 template_names = super().get_template_names()
117 except ImproperlyConfigured:
118 pass
119 suffix = ".html"
120 if hasattr(self, "template_name_suffix"):
121 suffix = self.template_name_suffix + ".html"
122 additional_templates = template_names_via_mro(self.model, suffix=suffix)
123 template_names += filter(
124 lambda template: template not in template_names, additional_templates
125 )
126 return template_names
129class List(
130 GenericModelMixin,
131 GenericModelPermissionRequiredMixin,
132 ExportMixin,
133 SingleTableMixin,
134 FilterView,
135):
136 """
137 List view for a generic model.
138 Access requires the `<model>_view` permission.
139 It is based on django-filters FilterView and django-tables SingleTableMixin.
140 The table class is overridden by the first match from
141 the `first_member_match` helper.
142 The filterset class is overridden by the first match from
143 the `first_member_match` helper.
144 The queryset is overridden by the first match from
145 the `first_member_match` helper.
146 """
148 template_name_suffix = "_list"
149 permission_action_required = "view"
151 def setup(self, *args, **kwargs):
152 super().setup(*args, **kwargs)
153 self.cookie_name = f"{self.model._meta.label_lower}-list"
154 cookie = QueryDict(self.request.COOKIES.get(self.cookie_name, ""))
155 get = self.request.GET.copy()
156 for prefix in ["filterset", "choices"]:
157 id_ = f"{prefix}-remember"
158 passed_prefix = any([prefix in key for key in self.request.GET.keys()])
159 use_cookie = cookie.get(id_, False) and not passed_prefix
160 if use_cookie:
161 for key in [key for key in cookie if key.startswith(prefix)]:
162 get.setlist(key, cookie.getlist(key))
163 if "sort" not in get.keys() and "sort" in cookie.keys():
164 get["sort"] = cookie.get("sort")
165 self.request.GET = get
167 def get(self, request, *args, **kwargs):
168 response = super().get(request, *args, **kwargs)
169 response.set_cookie(self.cookie_name, self.request.GET.urlencode())
170 return response
172 def get_table_class(self):
173 table_modules = module_paths(self.model, path="tables", suffix="Table")
174 table_class = first_member_match(table_modules, GenericTable)
175 return table_factory(self.model, table_class)
177 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
179 def get_export_filename(self, extension):
180 table_class = self.get_table_class()
181 if hasattr(table_class, "export_filename"):
182 return f"{table_class.export_filename}.{extension}"
184 return super().get_export_filename(extension)
186 def get_table_kwargs(self):
187 kwargs = super().get_table_kwargs()
189 selected_columns = self.request.GET.getlist("choices-columns", [])
190 modelfields = self.model._meta.get_fields()
191 # if the form was submitted, we look at the selected
192 # columns and exclude all columns that are not part of that list
193 if self.request.GET and "choices-columns" in self.request.GET:
194 columns_exclude = self.get_filterset_class().Meta.form.columns_exclude
195 other_columns = [
196 name for (name, field) in self._get_columns_choices(columns_exclude)
197 ]
198 kwargs["exclude"] = [
199 field for field in other_columns if field not in selected_columns
200 ]
202 # now we look at the selected columns and
203 # add all modelfields and annotated fields that
204 # are part of the selected columns to the extra_columns
205 annotationfields = list()
206 for key, value in self.object_list.query.annotations.items():
207 # we have to use copy, so we don't edit the original field
208 fake_field = copy(getattr(value, "field", value.output_field))
209 setattr(fake_field, "name", key)
210 annotationfields.append(fake_field)
211 extra_fields = list(
212 filter(
213 lambda x: x.name in selected_columns,
214 modelfields + tuple(annotationfields),
215 )
216 )
217 kwargs["extra_columns"] = [
218 (field.name, library.column_for_field(field, accessor=field.name))
219 for field in extra_fields
220 if field.name not in self.get_table_class().base_columns
221 ]
223 return kwargs
225 def get_filterset_class(self):
226 filterset_modules = module_paths(
227 self.model, path="filtersets", suffix="FilterSet"
228 )
229 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
230 return filterset_factory(self.model, filterset_class)
232 def _get_columns_choices(self, columns_exclude):
233 # lets start with the custom table fields
234 choices = {
235 key.name: capfirst(str(key) or key.name or "Nameless column")
236 for key in self.get_table().columns
237 }
238 # then add the model fields, but only the ones
239 # that are not automatically created (parent keys)
240 # and not the m2m relations and not any that are
241 # already part of the choices
242 choices |= {
243 field.name: pretty_name(getattr(field, "verbose_name", field.name))
244 for field in self.model._meta.get_fields()
245 if not getattr(field, "auto_created", False)
246 and not isinstance(field, ManyToManyRel)
247 and field.name not in choices.keys()
248 }
249 # finally we add any annotated fields
250 choices |= {key: key for key in self.get_queryset().query.annotations.keys()}
251 # now we drop all the choices that are listed in columns_exclude
252 choices = {
253 key: value for key, value in choices.items() if key not in columns_exclude
254 }
255 return choices.items()
257 def get_filterset_kwargs(self, filterset_class):
258 kwargs = super().get_filterset_kwargs(filterset_class)
259 kwargs["prefix"] = "filterset"
260 return kwargs
262 def get_filterset(self, filterset_class):
263 """
264 We override the `get_filterset` method, so we can add a
265 css class to the the selected filters
266 """
267 filterset = super().get_filterset(filterset_class)
269 # If the filterset form contains form data
270 # we add a CSS class to the element wrapping
271 # that field in HTML. This CSS class can be
272 # used to emphasize the fields that are used.
273 # To be able to compare the fields with the form
274 # data, we create a temporary mapping between
275 # widget_names and fields
276 fields = {}
277 for name, field in filterset.form.fields.items():
278 fields[name] = name
279 if hasattr(field.widget, "widgets_names"):
280 for widget_name in field.widget.widgets_names:
281 fields[name + widget_name] = name
282 if filterset.form.is_valid():
283 data = filterset.form.cleaned_data
284 for param in [param for param, value in data.items() if value]:
285 if fieldname := fields.get(param, None):
286 filterset.form.helper[fieldname].wrap(
287 Field, wrapper_class="filter-input-selected"
288 )
290 return filterset
292 def get_queryset(self):
293 queryset_methods = module_paths(
294 self.model, path="querysets", suffix="ListViewQueryset"
295 )
296 queryset = first_member_match(queryset_methods) or (lambda x: x)
297 return queryset(self.model.objects.all())
299 def get_table_pagination(self, table):
300 """
301 Override `get_table_pagination` from the tables2 TableMixinBase,
302 so we can set the table_pagination value as attribute of the table.
303 """
304 self.table_pagination = getattr(table, "table_pagination", None)
305 return super().get_table_pagination(table)
307 def get_context_data(self, *args, **kwargs):
308 context = super().get_context_data(*args, **kwargs)
309 table = context.get("table", None)
310 filterset = context.get("filter", None)
311 context["filterset_remember"] = (
312 self.request.GET.get("filterset-remember", "") == "on"
313 )
314 if table and filterset:
315 columns_exclude = filterset.form.columns_exclude
316 initial_columns = [
317 col.name for col in table.columns if col.name not in columns_exclude
318 ]
319 data = (
320 self.request.GET
321 if any(["choices" in key for key in self.request.GET.keys()])
322 else None
323 )
324 if choices := self._get_columns_choices(columns_exclude=columns_exclude):
325 context["columns_selector"] = ColumnsSelectorForm(
326 choices=choices,
327 initial={"choices": initial_columns},
328 prefix="choices",
329 data=data,
330 )
331 return context
334class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView):
335 """
336 Detail view for a generic model.
337 Access requires the `<model>_view` permission.
338 """
340 permission_action_required = "view"
343class Create(
344 GenericModelMixin,
345 GenericModelPermissionRequiredMixin,
346 SuccessMessageMixin,
347 CreateView,
348):
349 """
350 Create view for a generic model.
351 Access requires the `<model>_add` permission.
352 The form class is overridden by the first match from
353 the `first_member_match` helper.
354 """
356 template_name_suffix = "_create"
357 permission_action_required = "add"
359 def get_form_class(self):
360 form_modules = module_paths(self.model, path="forms", suffix="Form")
361 form_class = first_member_match(form_modules, GenericModelForm)
362 return modelform_factory(self.model, form_class)
364 def get_success_message(self, cleaned_data):
365 message_templates = template_names_via_mro(
366 self.model, suffix="_create_success_message.html"
367 )
368 template = select_template(message_templates)
369 return template.render({"object": self.object})
371 def get_success_url(self):
372 return self.object.get_create_success_url(request=self.request)
375class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView):
376 """
377 Delete view for a generic model.
378 Access requires the `<model>_delete` permission.
379 """
381 permission_action_required = "delete"
383 def get_success_url(self):
384 return self.object.get_delete_success_url(request=self.request)
387class Update(
388 GenericModelMixin,
389 GenericModelPermissionRequiredMixin,
390 SuccessMessageMixin,
391 UpdateView,
392):
393 """
394 Update view for a generic model.
395 Access requires the `<model>_change` permission.
396 The form class is overridden by the first match from
397 the `first_member_match` helper.
398 """
400 permission_action_required = "change"
402 def get_form_class(self):
403 form_modules = module_paths(self.model, path="forms", suffix="Form")
404 form_class = first_member_match(form_modules, GenericModelForm)
405 return modelform_factory(self.model, form_class)
407 def get_success_message(self, cleaned_data):
408 message_templates = template_names_via_mro(
409 self.model, suffix="_update_success_message.html"
410 )
411 template = select_template(message_templates)
412 return template.render({"object": self.object})
414 def get_success_url(self):
415 return self.object.get_update_success_url(request=self.request)
418class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View):
419 permission_action_required = "add"
421 def get(self, request, *args, **kwargs):
422 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
423 newobj = source_obj.duplicate()
425 message_templates = template_names_via_mro(
426 self.model, suffix="_duplicate_success_message.html"
427 )
428 template = select_template(message_templates)
429 messages.success(request, template.render({"object": source_obj}))
430 return redirect(newobj.get_edit_url())
433class Autocomplete(
434 GenericModelMixin,
435 GenericModelPermissionRequiredMixin,
436 autocomplete.Select2QuerySetView,
437):
438 """
439 Autocomplete view for a generic model.
440 Access requires the `<model>_view` permission.
441 The queryset is overridden by the first match from
442 the `first_member_match` helper.
443 """
445 permission_action_required = "view"
446 template_name_suffix = "_autocomplete_result"
448 def setup(self, *args, **kwargs):
449 super().setup(*args, **kwargs)
450 # We use a URI parameter to enable the create functionality in the
451 # autocomplete dropdown. It is not important what the value of the
452 # `create_field` is, because we override create_object anyway.
453 self.create_field = self.request.GET.get("create", None)
454 try:
455 template = select_template(self.get_template_names())
456 self.template = template.template.name
457 except TemplateDoesNotExist:
458 self.template = None
460 def get_queryset(self):
461 queryset_methods = module_paths(
462 self.model, path="querysets", suffix="AutocompleteQueryset"
463 )
464 queryset = first_member_match(queryset_methods)
465 if queryset:
466 return queryset(self.model, self.q)
467 return self.model.objects.filter(generate_search_filter(self.model, self.q))
469 def get_results(self, context):
470 external_only = self.kwargs.get("external_only", False)
471 results = [] if external_only else super().get_results(context)
472 queryset_methods = module_paths(
473 self.model, path="querysets", suffix="ExternalAutocomplete"
474 )
475 ExternalAutocomplete = first_member_match(queryset_methods)
476 if ExternalAutocomplete:
477 results.extend(ExternalAutocomplete().get_results(self.q))
478 return results
480 def create_object(self, value):
481 """
482 We try multiple approaches to create a model instance from a value:
483 * we first test if the value is an URL and if so we expect it to be
484 something that can be imported using one of the configured importers
485 and so we pass the value to the import logic.
486 * if the value is not a string, we try to pass it to the `create_from_string`
487 method of the model, if that does exist. Its the models responsibility to
488 implement this method and the method should somehow know how to create
489 model instance from the value...
490 * finally we pass the value to the `create_object` method from the DAL
491 view, which tries to pass it to `get_or_create` which likely also fails,
492 but this is expected and we raise a more useful exception.
493 """
494 try:
495 URLValidator()(value)
496 return self.queryset.model.import_from(value)
497 except ValidationError:
498 pass
499 try:
500 return self.queryset.model.create_from_string(value)
501 except AttributeError:
502 raise ImproperlyConfigured(
503 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
504 )
506 def post(self, request, *args, **kwargs):
507 try:
508 with transaction.atomic():
509 return super().post(request, *args, **kwargs)
510 except Exception as e:
511 logger.debug(traceback.format_exc())
512 return http.JsonResponse({"error": str(e)})
515class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
516 template_name_suffix = "_import"
517 permission_action_required = "add"
519 def get_form_class(self):
520 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
521 form_class = first_member_match(form_modules, GenericImportForm)
522 return modelform_factory(self.model, form_class)
524 def form_valid(self, form):
525 self.object = form.cleaned_data["url"]
526 for field, error in getattr(self.object, "_import_errors", {}).items():
527 messages.error(self.request, f"Could not set {field}: {error}")
528 return super().form_valid(form)
530 def get_success_url(self):
531 return self.object.get_absolute_url()
534class SelectMergeOrEnrich(
535 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView
536):
537 """
538 This view provides a simple form that allows to select other entities (also from
539 external sources, if set up) and on form submit redirects to the Enrich view.
540 """
542 template_name_suffix = "_selectmergeorenrich"
543 permission_action_required = "add"
544 form_class = GenericSelectMergeOrEnrichForm
546 def get_object(self, *args, **kwargs):
547 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
549 def get_context_data(self, *args, **kwargs):
550 context = super().get_context_data(*args, **kwargs)
551 context["object"] = self.get_object()
552 return context
554 def get_form_kwargs(self, *args, **kwargs):
555 kwargs = super().get_form_kwargs(*args, **kwargs)
556 kwargs["content_type"] = ContentType.objects.get_for_model(self.model)
557 return kwargs
559 def form_valid(self, form):
560 uri = form.cleaned_data["uri"]
561 if uri.isdigit():
562 return redirect(self.get_object().get_merge_url(uri))
563 return redirect(self.get_object().get_enrich_url() + f"?uri={uri}")
566class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
567 """
568 Generic merge view.
569 """
571 permission_action_required = "change"
572 form_class = GenericMergeWithForm
573 template_name_suffix = "_merge"
575 def setup(self, *args, **kwargs):
576 super().setup(*args, **kwargs)
577 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
578 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
580 def get_context_data(self, **kwargs):
581 """
582 The context consists of the two objects that are merged as well
583 as a list of changes. Those changes are presented in the view as
584 a table with diffs
585 """
586 Change = namedtuple("Change", "field old new")
587 ctx = super().get_context_data(**kwargs)
588 ctx["changes"] = []
589 for field in self.object._meta.fields:
590 newval = self.object.get_field_value_after_merge(self.other, field)
591 ctx["changes"].append(
592 Change(field.verbose_name, getattr(self.object, field.name), newval)
593 )
594 ctx["object"] = self.object
595 ctx["other"] = self.other
596 return ctx
598 def form_valid(self, form):
599 self.object.merge_with([self.other])
600 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
601 return super().form_valid(form)
603 def get_success_url(self):
604 return self.object.get_absolute_url()
607class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
608 """
609 Enrich an entity with data from an external source
610 Provides the user with a form to select the fields that should be updated.
611 """
613 permission_action_required = "change"
614 template_name_suffix = "_enrich"
615 form_class = GenericEnrichForm
616 importer_class = None
618 def setup(self, *args, **kwargs):
619 super().setup(*args, **kwargs)
620 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
621 _, self.uri = get_autocomplete_data_and_normalized_uri(
622 self.request.GET.get("uri")
623 )
624 if not self.uri:
625 messages.error(self.request, "No uri parameter specified.")
627 def get(self, *args, **kwargs):
628 try:
629 uriobj = Uri.objects.get(uri=self.uri)
630 if uriobj.object_id != self.object.id:
631 messages.info(
632 self.request,
633 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
634 )
635 return redirect(self.object.get_merge_url(uriobj.object_id))
636 except Uri.DoesNotExist:
637 pass
638 return super().get(*args, **kwargs)
640 def get_context_data(self, **kwargs):
641 ctx = super().get_context_data(**kwargs)
642 ctx["object"] = self.object
643 ctx["uri"] = self.uri
644 return ctx
646 def get_form_kwargs(self, *args, **kwargs):
647 kwargs = super().get_form_kwargs(*args, **kwargs)
648 kwargs["instance"] = self.object
649 try:
650 self.data = self.model.fetch_from(self.request.GET.get("uri"))
651 kwargs["data"] = self.data
652 except ImproperlyConfigured as e:
653 messages.error(self.request, e)
654 return kwargs
656 def form_valid(self, form):
657 """
658 Go through all the form fields and extract the ones that
659 start with `update_` and that are set (those are the checkboxes that
660 select which fields to update).
661 Create a dict from those values, add the uri and pass the dict on to
662 the models `import_data` method.
663 """
664 data = {}
665 for key, values in self.request.POST.items():
666 if key.startswith("update_"):
667 key = key.removeprefix("update_")
668 data[key] = self.data[key]
669 if data:
670 self.object.import_data(data)
671 for field, error in getattr(self.object, "_import_errors", {}).items():
672 messages.error(self.request, f"Could not update {field}: {error}")
673 messages.info(self.request, f"Updated fields {data.keys()}")
674 return super().form_valid(form)
676 def get_success_url(self):
677 return self.object.get_absolute_url()