Coverage for apis_core/generic/views.py: 81%
353 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-08-21 08:53 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-08-21 08:53 +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 content_type = ContentType.objects.get_for_model(self.model)
154 self.cookie_name = f"{content_type.app_label}.{content_type.model}-list"
155 cookie = QueryDict(self.request.COOKIES.get(self.cookie_name, ""))
156 get = self.request.GET.copy()
157 for prefix in ["filterset", "choices"]:
158 id_ = f"{prefix}-remember"
159 passed_prefix = any([prefix in key for key in self.request.GET.keys()])
160 use_cookie = cookie.get(id_, False) and not passed_prefix
161 if use_cookie:
162 for key in [key for key in cookie if key.startswith(prefix)]:
163 get.setlist(key, cookie.getlist(key))
164 if "sort" not in get.keys() and "sort" in cookie.keys():
165 get["sort"] = cookie.get("sort")
166 self.request.GET = get
168 def get(self, request, *args, **kwargs):
169 response = super().get(request, *args, **kwargs)
170 response.set_cookie(self.cookie_name, self.request.GET.urlencode())
171 return response
173 def get_table_class(self):
174 table_modules = module_paths(self.model, path="tables", suffix="Table")
175 table_class = first_member_match(table_modules, GenericTable)
176 return table_factory(self.model, table_class)
178 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
180 def get_export_filename(self, extension):
181 table_class = self.get_table_class()
182 if hasattr(table_class, "export_filename"):
183 return f"{table_class.export_filename}.{extension}"
185 return super().get_export_filename(extension)
187 def get_table_kwargs(self):
188 kwargs = super().get_table_kwargs()
190 selected_columns = self.request.GET.getlist("choices-columns", [])
191 modelfields = self.model._meta.get_fields()
192 # if the form was submitted, we look at the selected
193 # columns and exclude all columns that are not part of that list
194 if self.request.GET and "choices-columns" in self.request.GET:
195 columns_exclude = self.get_filterset_class().Meta.form.columns_exclude
196 other_columns = [
197 name for (name, field) in self._get_columns_choices(columns_exclude)
198 ]
199 kwargs["exclude"] = [
200 field for field in other_columns if field not in selected_columns
201 ]
203 # now we look at the selected columns and
204 # add all modelfields and annotated fields that
205 # are part of the selected columns to the extra_columns
206 annotationfields = list()
207 for key, value in self.object_list.query.annotations.items():
208 # we have to use copy, so we don't edit the original field
209 fake_field = copy(getattr(value, "field", value.output_field))
210 setattr(fake_field, "name", key)
211 annotationfields.append(fake_field)
212 extra_fields = list(
213 filter(
214 lambda x: x.name in selected_columns,
215 modelfields + tuple(annotationfields),
216 )
217 )
218 kwargs["extra_columns"] = [
219 (field.name, library.column_for_field(field, accessor=field.name))
220 for field in extra_fields
221 if field.name not in self.get_table_class().base_columns
222 ]
224 return kwargs
226 def get_filterset_class(self):
227 filterset_modules = module_paths(
228 self.model, path="filtersets", suffix="FilterSet"
229 )
230 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
231 return filterset_factory(self.model, filterset_class)
233 def _get_columns_choices(self, columns_exclude):
234 # lets start with the custom table fields
235 choices = {
236 key.name: capfirst(str(key) or key.name or "Nameless column")
237 for key in self.get_table().columns
238 }
239 # then add the model fields, but only the ones
240 # that are not automatically created (parent keys)
241 # and not the m2m relations and not any that are
242 # already part of the choices
243 choices |= {
244 field.name: pretty_name(getattr(field, "verbose_name", field.name))
245 for field in self.model._meta.get_fields()
246 if not getattr(field, "auto_created", False)
247 and not isinstance(field, ManyToManyRel)
248 and field.name not in choices.keys()
249 }
250 # finally we add any annotated fields
251 choices |= {key: key for key in self.get_queryset().query.annotations.keys()}
252 # now we drop all the choices that are listed in columns_exclude
253 choices = {
254 key: value for key, value in choices.items() if key not in columns_exclude
255 }
256 return choices.items()
258 def get_filterset_kwargs(self, filterset_class):
259 kwargs = super().get_filterset_kwargs(filterset_class)
260 kwargs["prefix"] = "filterset"
261 return kwargs
263 def get_filterset(self, filterset_class):
264 """
265 We override the `get_filterset` method, so we can add a
266 css class to the the selected filters
267 """
268 filterset = super().get_filterset(filterset_class)
270 # If the filterset form contains form data
271 # we add a CSS class to the element wrapping
272 # that field in HTML. This CSS class can be
273 # used to emphasize the fields that are used.
274 # To be able to compare the fields with the form
275 # data, we create a temporary mapping between
276 # widget_names and fields
277 fields = {}
278 for name, field in filterset.form.fields.items():
279 fields[name] = name
280 if hasattr(field.widget, "widgets_names"):
281 for widget_name in field.widget.widgets_names:
282 fields[name + widget_name] = name
283 if filterset.form.is_valid():
284 data = filterset.form.cleaned_data
285 for param in [param for param, value in data.items() if value]:
286 if fieldname := fields.get(param, None):
287 filterset.form.helper[fieldname].wrap(
288 Field, wrapper_class="filter-input-selected"
289 )
291 return filterset
293 def get_queryset(self):
294 queryset_methods = module_paths(
295 self.model, path="querysets", suffix="ListViewQueryset"
296 )
297 queryset = first_member_match(queryset_methods) or (lambda x: x)
298 return queryset(self.model.objects.all())
300 def get_table_pagination(self, table):
301 """
302 Override `get_table_pagination` from the tables2 TableMixinBase,
303 so we can set the table_pagination value as attribute of the table.
304 """
305 self.table_pagination = getattr(table, "table_pagination", None)
306 return super().get_table_pagination(table)
308 def get_context_data(self, *args, **kwargs):
309 context = super().get_context_data(*args, **kwargs)
310 table = context.get("table", None)
311 filterset = context.get("filter", None)
312 context["filterset_remember"] = (
313 self.request.GET.get("filterset-remember", "") == "on"
314 )
315 if table and filterset:
316 columns_exclude = filterset.form.columns_exclude
317 initial_columns = [
318 col.name for col in table.columns if col.name not in columns_exclude
319 ]
320 data = (
321 self.request.GET
322 if any(["choices" in key for key in self.request.GET.keys()])
323 else None
324 )
325 if choices := self._get_columns_choices(columns_exclude=columns_exclude):
326 context["columns_selector"] = ColumnsSelectorForm(
327 choices=choices,
328 initial={"choices": initial_columns},
329 prefix="choices",
330 data=data,
331 )
332 return context
335class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView):
336 """
337 Detail view for a generic model.
338 Access requires the `<model>_view` permission.
339 """
341 permission_action_required = "view"
344class Create(
345 GenericModelMixin,
346 GenericModelPermissionRequiredMixin,
347 SuccessMessageMixin,
348 CreateView,
349):
350 """
351 Create view for a generic model.
352 Access requires the `<model>_add` permission.
353 The form class is overridden by the first match from
354 the `first_member_match` helper.
355 """
357 template_name_suffix = "_create"
358 permission_action_required = "add"
360 def get_form_class(self):
361 form_modules = module_paths(self.model, path="forms", suffix="Form")
362 form_class = first_member_match(form_modules, GenericModelForm)
363 return modelform_factory(self.model, form_class)
365 def get_success_message(self, cleaned_data):
366 message_templates = template_names_via_mro(
367 self.model, suffix="_create_success_message.html"
368 )
369 template = select_template(message_templates)
370 return template.render({"object": self.object})
372 def get_success_url(self):
373 return self.object.get_create_success_url(request=self.request)
376class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView):
377 """
378 Delete view for a generic model.
379 Access requires the `<model>_delete` permission.
380 """
382 permission_action_required = "delete"
384 def get_success_url(self):
385 return self.object.get_delete_success_url(request=self.request)
388class Update(
389 GenericModelMixin,
390 GenericModelPermissionRequiredMixin,
391 SuccessMessageMixin,
392 UpdateView,
393):
394 """
395 Update view for a generic model.
396 Access requires the `<model>_change` permission.
397 The form class is overridden by the first match from
398 the `first_member_match` helper.
399 """
401 permission_action_required = "change"
403 def get_form_class(self):
404 form_modules = module_paths(self.model, path="forms", suffix="Form")
405 form_class = first_member_match(form_modules, GenericModelForm)
406 return modelform_factory(self.model, form_class)
408 def get_success_message(self, cleaned_data):
409 message_templates = template_names_via_mro(
410 self.model, suffix="_update_success_message.html"
411 )
412 template = select_template(message_templates)
413 return template.render({"object": self.object})
415 def get_success_url(self):
416 return self.object.get_update_success_url(request=self.request)
419class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View):
420 permission_action_required = "add"
422 def get(self, request, *args, **kwargs):
423 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
424 newobj = source_obj.duplicate()
426 message_templates = template_names_via_mro(
427 self.model, suffix="_duplicate_success_message.html"
428 )
429 template = select_template(message_templates)
430 messages.success(request, template.render({"object": source_obj}))
431 return redirect(newobj.get_edit_url())
434class Autocomplete(
435 GenericModelMixin,
436 GenericModelPermissionRequiredMixin,
437 autocomplete.Select2QuerySetView,
438):
439 """
440 Autocomplete view for a generic model.
441 Access requires the `<model>_view` permission.
442 The queryset is overridden by the first match from
443 the `first_member_match` helper.
444 """
446 permission_action_required = "view"
447 template_name_suffix = "_autocomplete_result"
449 def setup(self, *args, **kwargs):
450 super().setup(*args, **kwargs)
451 # We use a URI parameter to enable the create functionality in the
452 # autocomplete dropdown. It is not important what the value of the
453 # `create_field` is, because we override create_object anyway.
454 self.create_field = self.request.GET.get("create", None)
455 try:
456 template = select_template(self.get_template_names())
457 self.template = template.template.name
458 except TemplateDoesNotExist:
459 self.template = None
461 def get_queryset(self):
462 queryset_methods = module_paths(
463 self.model, path="querysets", suffix="AutocompleteQueryset"
464 )
465 queryset = first_member_match(queryset_methods)
466 if queryset:
467 return queryset(self.model, self.q)
468 return self.model.objects.filter(generate_search_filter(self.model, self.q))
470 def get_results(self, context):
471 external_only = self.kwargs.get("external_only", False)
472 results = [] if external_only else super().get_results(context)
473 queryset_methods = module_paths(
474 self.model, path="querysets", suffix="ExternalAutocomplete"
475 )
476 ExternalAutocomplete = first_member_match(queryset_methods)
477 if ExternalAutocomplete:
478 results.extend(ExternalAutocomplete().get_results(self.q))
479 return results
481 def create_object(self, value):
482 """
483 We try multiple approaches to create a model instance from a value:
484 * we first test if the value is an URL and if so we expect it to be
485 something that can be imported using one of the configured importers
486 and so we pass the value to the import logic.
487 * if the value is not a string, we try to pass it to the `create_from_string`
488 method of the model, if that does exist. Its the models responsibility to
489 implement this method and the method should somehow know how to create
490 model instance from the value...
491 * finally we pass the value to the `create_object` method from the DAL
492 view, which tries to pass it to `get_or_create` which likely also fails,
493 but this is expected and we raise a more useful exception.
494 """
495 try:
496 URLValidator()(value)
497 return self.queryset.model.import_from(value)
498 except ValidationError:
499 pass
500 try:
501 return self.queryset.model.create_from_string(value)
502 except AttributeError:
503 raise ImproperlyConfigured(
504 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
505 )
507 def post(self, request, *args, **kwargs):
508 try:
509 with transaction.atomic():
510 return super().post(request, *args, **kwargs)
511 except Exception as e:
512 logger.debug(traceback.format_exc())
513 return http.JsonResponse({"error": str(e)})
516class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
517 template_name_suffix = "_import"
518 permission_action_required = "add"
520 def get_form_class(self):
521 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
522 form_class = first_member_match(form_modules, GenericImportForm)
523 return modelform_factory(self.model, form_class)
525 def form_valid(self, form):
526 self.object = form.cleaned_data["url"]
527 for field, error in getattr(self.object, "_import_errors", {}).items():
528 messages.error(self.request, f"Could not set {field}: {error}")
529 return super().form_valid(form)
531 def get_success_url(self):
532 return self.object.get_absolute_url()
535class SelectMergeOrEnrich(
536 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView
537):
538 """
539 This view provides a simple form that allows to select other entities (also from
540 external sources, if set up) and on form submit redirects to the Enrich view.
541 """
543 template_name_suffix = "_selectmergeorenrich"
544 permission_action_required = "add"
545 form_class = GenericSelectMergeOrEnrichForm
547 def get_object(self, *args, **kwargs):
548 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
550 def get_context_data(self, *args, **kwargs):
551 context = super().get_context_data(*args, **kwargs)
552 context["object"] = self.get_object()
553 return context
555 def get_form_kwargs(self, *args, **kwargs):
556 kwargs = super().get_form_kwargs(*args, **kwargs)
557 kwargs["content_type"] = ContentType.objects.get_for_model(self.model)
558 return kwargs
560 def form_valid(self, form):
561 uri = form.cleaned_data["uri"]
562 if uri.isdigit():
563 return redirect(self.get_object().get_merge_url(uri))
564 return redirect(self.get_object().get_enrich_url() + f"?uri={uri}")
567class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
568 """
569 Generic merge view.
570 """
572 permission_action_required = "change"
573 form_class = GenericMergeWithForm
574 template_name_suffix = "_merge"
576 def setup(self, *args, **kwargs):
577 super().setup(*args, **kwargs)
578 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
579 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
581 def get_context_data(self, **kwargs):
582 """
583 The context consists of the two objects that are merged as well
584 as a list of changes. Those changes are presented in the view as
585 a table with diffs
586 """
587 Change = namedtuple("Change", "field old new")
588 ctx = super().get_context_data(**kwargs)
589 ctx["changes"] = []
590 for field in self.object._meta.fields:
591 newval = self.object.get_field_value_after_merge(self.other, field)
592 ctx["changes"].append(
593 Change(field.verbose_name, getattr(self.object, field.name), newval)
594 )
595 ctx["object"] = self.object
596 ctx["other"] = self.other
597 return ctx
599 def form_valid(self, form):
600 self.object.merge_with([self.other])
601 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
602 return super().form_valid(form)
604 def get_success_url(self):
605 return self.object.get_absolute_url()
608class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
609 """
610 Enrich an entity with data from an external source
611 Provides the user with a form to select the fields that should be updated.
612 """
614 permission_action_required = "change"
615 template_name_suffix = "_enrich"
616 form_class = GenericEnrichForm
617 importer_class = None
619 def setup(self, *args, **kwargs):
620 super().setup(*args, **kwargs)
621 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
622 _, self.uri = get_autocomplete_data_and_normalized_uri(
623 self.request.GET.get("uri")
624 )
625 if not self.uri:
626 messages.error(self.request, "No uri parameter specified.")
628 def get(self, *args, **kwargs):
629 try:
630 uriobj = Uri.objects.get(uri=self.uri)
631 if uriobj.object_id != self.object.id:
632 messages.info(
633 self.request,
634 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
635 )
636 return redirect(self.object.get_merge_url(uriobj.object_id))
637 except Uri.DoesNotExist:
638 pass
639 return super().get(*args, **kwargs)
641 def get_context_data(self, **kwargs):
642 ctx = super().get_context_data(**kwargs)
643 ctx["object"] = self.object
644 ctx["uri"] = self.uri
645 return ctx
647 def get_form_kwargs(self, *args, **kwargs):
648 kwargs = super().get_form_kwargs(*args, **kwargs)
649 kwargs["instance"] = self.object
650 try:
651 self.data = self.model.fetch_from(self.request.GET.get("uri"))
652 kwargs["data"] = self.data
653 except ImproperlyConfigured as e:
654 messages.error(self.request, e)
655 return kwargs
657 def form_valid(self, form):
658 """
659 Go through all the form fields and extract the ones that
660 start with `update_` and that are set (those are the checkboxes that
661 select which fields to update).
662 Create a dict from those values, add the uri and pass the dict on to
663 the models `import_data` method.
664 """
665 data = {}
666 for key, values in self.request.POST.items():
667 if key.startswith("update_"):
668 key = key.removeprefix("update_")
669 data[key] = self.data[key]
670 if data:
671 self.object.import_data(data)
672 for field, error in getattr(self.object, "_import_errors", {}).items():
673 messages.error(self.request, f"Could not update {field}: {error}")
674 messages.info(self.request, f"Updated fields {data.keys()}")
675 return super().form_valid(form)
677 def get_success_url(self):
678 return self.object.get_absolute_url()