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