Coverage for apis_core/generic/views.py: 83%
326 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-10-30 12:03 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-10-30 12:03 +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.http import QueryDict
18from django.shortcuts import get_object_or_404, redirect
19from django.template.exceptions import TemplateDoesNotExist
20from django.template.loader import select_template
21from django.urls import reverse
22from django.utils.text import capfirst
23from django.views import View
24from django.views.generic import DetailView
25from django.views.generic.base import TemplateView
26from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
27from django_filters.filterset import filterset_factory
28from django_filters.views import FilterView
29from django_tables2 import SingleTableMixin
30from django_tables2.columns import library
31from django_tables2.export.views import ExportMixin
32from django_tables2.tables import table_factory
34from apis_core.uris.models import Uri
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 GenericModelPermissionRequiredMixin(PermissionRequiredMixin):
59 """
60 Verify that the current user has the required permission for this model.
61 The model overrides the `PermissionRequiredMixin.get_permission_required`
62 method to generate the required permission name on the fly, based on a
63 verb (`permission_action_required`) and the model this view act upon.
64 This allows us to set `permission_action_required` simply to `add`, or
65 `view` and reuse the mixin for views that work with different models.
66 In addition, for the views that have `permission_action_required` set to
67 `view`, it check if there is the global setting `APIS_ANON_VIEWS_ALLOWED`
68 set to `True`, which permits anonymouse users access to the view.
69 """
71 def get_permission_required(self):
72 if not hasattr(self, "model"):
73 raise ImproperlyConfigured(
74 f"{self.__class__.__name__} is missing the model attribute"
75 )
76 if getattr(self, "permission_action_required", None) == "view" and getattr(
77 settings, "APIS_ANON_VIEWS_ALLOWED", False
78 ):
79 return []
80 if hasattr(self, "permission_action_required"):
81 return [permission_fullname(self.permission_action_required, self.model)]
82 return []
85class GenericModelMixin:
86 """
87 A mixin providing the common functionality for all the views working
88 with `generic` models - that is models that are accessed via the
89 contenttype framework (using `app_label.model`).
90 It sets the `.model` of the view and generates a list of possible template
91 names (based on the MRO of the model).
92 If the view has a `permission_action_required` attribute, this is used
93 to set the permission required to access the view for this specific model.
94 """
96 def setup(self, *args, **kwargs):
97 super().setup(*args, **kwargs)
98 if contenttype := kwargs.get("contenttype"):
99 self.model = contenttype.model_class()
100 self.queryset = self.model.objects.all()
102 def get_template_names(self):
103 template_names = []
104 if hasattr(super(), "get_template_names"):
105 # Some parent classes come with custom template_names,
106 # some need a `.template_name` attribute set. For the
107 # latter ones we handle the missing `.template_name`
108 # gracefully
109 try:
110 template_names = super().get_template_names()
111 except ImproperlyConfigured:
112 pass
113 suffix = ".html"
114 if hasattr(self, "template_name_suffix"):
115 suffix = self.template_name_suffix + ".html"
116 additional_templates = template_names_via_mro(self.model, suffix=suffix)
117 template_names += filter(
118 lambda template: template not in template_names, additional_templates
119 )
120 return template_names
123class List(
124 GenericModelMixin,
125 GenericModelPermissionRequiredMixin,
126 ExportMixin,
127 SingleTableMixin,
128 FilterView,
129):
130 """
131 List view for a generic model.
132 Access requires the `<model>_view` permission.
133 It is based on django-filters FilterView and django-tables SingleTableMixin.
134 The table class is overridden by the first match from
135 the `first_member_match` helper.
136 The filterset class is overridden by the first match from
137 the `first_member_match` helper.
138 The queryset is overridden by the first match from
139 the `first_member_match` helper.
140 """
142 template_name_suffix = "_list"
143 permission_action_required = "view"
145 def get_table_class(self):
146 table_modules = module_paths(self.model, path="tables", suffix="Table")
147 table_class = first_member_match(table_modules, GenericTable)
148 return table_factory(self.model, table_class)
150 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"])
152 def get_export_filename(self, extension):
153 table_class = self.get_table_class()
154 if hasattr(table_class, "export_filename"):
155 return f"{table_class.export_filename}.{extension}"
157 return super().get_export_filename(extension)
159 def get_table_kwargs(self):
160 kwargs = super().get_table_kwargs()
162 selected_columns = self.request.GET.getlist("columns", [])
163 modelfields = self.model._meta.get_fields()
164 # if the form was submitted, we look at the selected
165 # columns and exclude all columns that are not part of that list
166 if self.request.GET and "columns" in self.request.GET:
167 columns_exclude = self.get_filterset_class().Meta.form.columns_exclude
168 other_columns = [
169 name for (name, field) in self._get_columns_choices(columns_exclude)
170 ]
171 kwargs["exclude"] = [
172 field for field in other_columns if field not in selected_columns
173 ]
175 # now we look at the selected columns and
176 # add all modelfields and annotated fields that
177 # are part of the selected columns to the extra_columns
178 annotationfields = list()
179 for key, value in self.object_list.query.annotations.items():
180 # we have to use copy, so we don't edit the original field
181 fake_field = copy(getattr(value, "field", value.output_field))
182 setattr(fake_field, "name", key)
183 annotationfields.append(fake_field)
184 extra_fields = list(
185 filter(
186 lambda x: x.name in selected_columns,
187 modelfields + tuple(annotationfields),
188 )
189 )
190 kwargs["extra_columns"] = [
191 (field.name, library.column_for_field(field, accessor=field.name))
192 for field in extra_fields
193 if field.name not in self.get_table_class().base_columns
194 ]
196 return kwargs
198 def get_filterset_class(self):
199 filterset_modules = module_paths(
200 self.model, path="filtersets", suffix="FilterSet"
201 )
202 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
203 return filterset_factory(self.model, filterset_class)
205 def _get_columns_choices(self, columns_exclude):
206 # we start with the model fields
207 choices = [
208 (field.name, pretty_name(getattr(field, "verbose_name", field.name)))
209 for field in self.model._meta.get_fields()
210 if not getattr(field, "auto_created", False)
211 and not isinstance(field, ManyToManyRel)
212 ]
213 # we add any annotated fields to that
214 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
215 # lets also add the custom table fields
216 choices += [
217 (key.name, capfirst(str(key) or key.name or "Nameless column"))
218 for key in self.get_table().columns
219 ]
220 # now we drop all the choices that are listed in columns_exclude
221 choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
222 return choices
224 def get_filterset_kwargs(self, filterset_class):
225 columns_exclude = filterset_class.Meta.form.columns_exclude
226 table_columns = self.get_table().columns
227 initial_columns = [
228 col.name for col in table_columns if col.name not in columns_exclude
229 ]
230 kwargs = super().get_filterset_kwargs(filterset_class)
231 data = (kwargs.get("data", QueryDict()) or QueryDict()).copy()
232 if not data.get("columns"):
233 data.setlist("columns", initial_columns)
234 kwargs["data"] = data
235 return kwargs
237 def get_filterset(self, filterset_class):
238 """
239 We override the `get_filterset` method, so we can inject a
240 `columns` selector into the form
241 """
242 filterset = super().get_filterset(filterset_class)
243 columns_exclude = filterset.form.columns_exclude
245 # we inject a `columns` selector in the beginning of the form
246 if choices := self._get_columns_choices(columns_exclude):
247 columns = forms.MultipleChoiceField(
248 required=False,
249 choices=choices,
250 )
251 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
252 # rebuild the layout, now that the columns field was added
253 filterset.form.helper.layout = filterset.form.helper.build_default_layout(
254 filterset.form
255 )
256 # If the filterset form contains form data
257 # we add a CSS class to the element wrapping
258 # that field in HTML. This CSS class can be
259 # used to emphasize the fields that are used.
260 # To be able to compare the fields with the form
261 # data, we create a temporary mapping between
262 # widget_names and fields
263 fields = {}
264 for name, field in filterset.form.fields.items():
265 fields[name] = name
266 if hasattr(field.widget, "widgets_names"):
267 for widget_name in field.widget.widgets_names:
268 fields[name + widget_name] = name
269 if data := filterset.form.data:
270 for param in [param for param, value in data.items() if value]:
271 if fieldname := fields.get(param, None):
272 filterset.form.helper[fieldname].wrap(
273 Field, wrapper_class="filter-input-selected"
274 )
276 return filterset
278 def get_queryset(self):
279 queryset_methods = module_paths(
280 self.model, path="querysets", suffix="ListViewQueryset"
281 )
282 queryset = first_member_match(queryset_methods) or (lambda x: x)
283 return queryset(self.model.objects.all())
285 def get_table_pagination(self, table):
286 """
287 Override `get_table_pagination` from the tables2 TableMixinBase,
288 so we can set the table_pagination value as attribute of the table.
289 """
290 self.table_pagination = getattr(table, "table_pagination", None)
291 return super().get_table_pagination(table)
294class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView):
295 """
296 Detail view for a generic model.
297 Access requires the `<model>_view` permission.
298 """
300 permission_action_required = "view"
303class Create(
304 GenericModelMixin,
305 GenericModelPermissionRequiredMixin,
306 SuccessMessageMixin,
307 CreateView,
308):
309 """
310 Create view for a generic model.
311 Access requires the `<model>_add` permission.
312 The form class is overridden by the first match from
313 the `first_member_match` helper.
314 """
316 template_name_suffix = "_create"
317 permission_action_required = "add"
319 def get_form_class(self):
320 form_modules = module_paths(self.model, path="forms", suffix="Form")
321 form_class = first_member_match(form_modules, GenericModelForm)
322 return modelform_factory(self.model, form_class)
324 def get_success_message(self, cleaned_data):
325 message_templates = template_names_via_mro(
326 self.model, suffix="_create_success_message.html"
327 )
328 template = select_template(message_templates)
329 return template.render({"object": self.object})
331 def get_success_url(self):
332 return self.object.get_create_success_url()
335class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView):
336 """
337 Delete view for a generic model.
338 Access requires the `<model>_delete` permission.
339 """
341 permission_action_required = "delete"
343 def get_success_url(self):
344 if redirect := self.request.GET.get("redirect"):
345 return redirect
346 return reverse(
347 "apis_core:generic:list",
348 args=[self.request.resolver_match.kwargs["contenttype"]],
349 )
352class Update(
353 GenericModelMixin,
354 GenericModelPermissionRequiredMixin,
355 SuccessMessageMixin,
356 UpdateView,
357):
358 """
359 Update view for a generic model.
360 Access requires the `<model>_change` permission.
361 The form class is overridden by the first match from
362 the `first_member_match` helper.
363 """
365 permission_action_required = "change"
367 def get_form_class(self):
368 form_modules = module_paths(self.model, path="forms", suffix="Form")
369 form_class = first_member_match(form_modules, GenericModelForm)
370 return modelform_factory(self.model, form_class)
372 def get_success_message(self, cleaned_data):
373 message_templates = template_names_via_mro(
374 self.model, suffix="_update_success_message.html"
375 )
376 template = select_template(message_templates)
377 return template.render({"object": self.object})
379 def get_success_url(self):
380 return self.object.get_update_success_url()
383class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View):
384 permission_action_required = "add"
386 def get(self, request, *args, **kwargs):
387 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
388 newobj = source_obj.duplicate()
390 message_templates = template_names_via_mro(
391 self.model, suffix="_duplicate_success_message.html"
392 )
393 template = select_template(message_templates)
394 messages.success(request, template.render({"object": source_obj}))
395 return redirect(newobj.get_edit_url())
398class Autocomplete(
399 GenericModelMixin,
400 GenericModelPermissionRequiredMixin,
401 autocomplete.Select2QuerySetView,
402):
403 """
404 Autocomplete view for a generic model.
405 Access requires the `<model>_view` permission.
406 The queryset is overridden by the first match from
407 the `first_member_match` helper.
408 """
410 permission_action_required = "view"
411 template_name_suffix = "_autocomplete_result"
413 def setup(self, *args, **kwargs):
414 super().setup(*args, **kwargs)
415 # We use a URI parameter to enable the create functionality in the
416 # autocomplete dropdown. It is not important what the value of the
417 # `create_field` is, because we override create_object anyway.
418 self.create_field = self.request.GET.get("create", None)
419 try:
420 template = select_template(self.get_template_names())
421 self.template = template.template.name
422 except TemplateDoesNotExist:
423 self.template = None
425 def get_queryset(self):
426 queryset_methods = module_paths(
427 self.model, path="querysets", suffix="AutocompleteQueryset"
428 )
429 queryset = first_member_match(queryset_methods)
430 if queryset:
431 return queryset(self.model, self.q)
432 return self.model.objects.filter(generate_search_filter(self.model, self.q))
434 def get_results(self, context):
435 external_only = self.kwargs.get("external_only", False)
436 results = [] if external_only else super().get_results(context)
437 queryset_methods = module_paths(
438 self.model, path="querysets", suffix="ExternalAutocomplete"
439 )
440 ExternalAutocomplete = first_member_match(queryset_methods)
441 if ExternalAutocomplete:
442 results.extend(ExternalAutocomplete().get_results(self.q))
443 return results
445 def create_object(self, value):
446 """
447 We try multiple approaches to create a model instance from a value:
448 * we first test if the value is an URL and if so we expect it to be
449 something that can be imported using one of the configured importers
450 and so we pass the value to the import logic.
451 * if the value is not a string, we try to pass it to the `create_from_string`
452 method of the model, if that does exist. Its the models responsibility to
453 implement this method and the method should somehow know how to create
454 model instance from the value...
455 * finally we pass the value to the `create_object` method from the DAL
456 view, which tries to pass it to `get_or_create` which likely also fails,
457 but this is expected and we raise a more useful exception.
458 """
459 try:
460 URLValidator()(value)
461 return self.queryset.model.import_from(value)
462 except ValidationError:
463 pass
464 try:
465 return self.queryset.model.create_from_string(value)
466 except AttributeError:
467 raise ImproperlyConfigured(
468 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
469 )
471 def post(self, request, *args, **kwargs):
472 try:
473 return super().post(request, *args, **kwargs)
474 except Exception as e:
475 return http.JsonResponse({"error": str(e)})
478class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
479 template_name_suffix = "_import"
480 permission_action_required = "add"
482 def get_form_class(self):
483 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
484 form_class = first_member_match(form_modules, GenericImportForm)
485 return modelform_factory(self.model, form_class)
487 def form_valid(self, form):
488 self.object = form.cleaned_data["url"]
489 return super().form_valid(form)
491 def get_success_url(self):
492 return self.object.get_absolute_url()
495class SelectMergeOrEnrich(
496 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView
497):
498 """
499 This view provides a simple form that allows to select other entities (also from
500 external sources, if set up) and on form submit redirects to the Enrich view.
501 """
503 template_name_suffix = "_selectmergeorenrich"
504 permission_action_required = "add"
505 form_class = GenericSelectMergeOrEnrichForm
507 def get_object(self, *args, **kwargs):
508 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
510 def get_context_data(self, *args, **kwargs):
511 context = super().get_context_data(*args, **kwargs)
512 context["object"] = self.get_object()
513 return context
515 def get_form_kwargs(self, *args, **kwargs):
516 kwargs = super().get_form_kwargs(*args, **kwargs)
517 kwargs["content_type"] = ContentType.objects.get_for_model(self.model)
518 return kwargs
520 def form_valid(self, form):
521 uri = form.cleaned_data["uri"]
522 if uri.isdigit():
523 return redirect(self.get_object().get_merge_url(uri))
524 return redirect(self.get_object().get_enrich_url() + f"?uri={uri}")
527class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
528 """
529 Generic merge view.
530 """
532 permission_action_required = "change"
533 form_class = GenericMergeWithForm
534 template_name_suffix = "_merge"
536 def setup(self, *args, **kwargs):
537 super().setup(*args, **kwargs)
538 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
539 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
541 def get_context_data(self, **kwargs):
542 """
543 The context consists of the two objects that are merged as well
544 as a list of changes. Those changes are presented in the view as
545 a table with diffs
546 """
547 Change = namedtuple("Change", "field old new")
548 ctx = super().get_context_data(**kwargs)
549 ctx["changes"] = []
550 for field in self.object._meta.fields:
551 newval = self.object.get_field_value_after_merge(self.other, field)
552 ctx["changes"].append(
553 Change(field.verbose_name, getattr(self.object, field.name), newval)
554 )
555 ctx["object"] = self.object
556 ctx["other"] = self.other
557 return ctx
559 def form_valid(self, form):
560 self.object.merge_with([self.other])
561 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
562 return super().form_valid(form)
564 def get_success_url(self):
565 return self.object.get_absolute_url()
568class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView):
569 """
570 Enrich an entity with data from an external source
571 Provides the user with a form to select the fields that should be updated.
572 """
574 permission_action_required = "change"
575 template_name_suffix = "_enrich"
576 form_class = GenericEnrichForm
577 importer_class = None
579 def setup(self, *args, **kwargs):
580 super().setup(*args, **kwargs)
581 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
582 self.uri = self.request.GET.get("uri")
583 if not self.uri:
584 messages.error(self.request, "No uri parameter specified.")
586 def get(self, *args, **kwargs):
587 try:
588 uriobj = Uri.objects.get(uri=self.uri)
589 if uriobj.object_id != self.object.id:
590 messages.info(
591 self.request,
592 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
593 )
594 return redirect(self.object.get_merge_url(uriobj.object_id))
595 except Uri.DoesNotExist:
596 pass
597 return super().get(*args, **kwargs)
599 def get_context_data(self, **kwargs):
600 ctx = super().get_context_data(**kwargs)
601 ctx["object"] = self.object
602 ctx["uri"] = self.uri
603 return ctx
605 def get_form_kwargs(self, *args, **kwargs):
606 kwargs = super().get_form_kwargs(*args, **kwargs)
607 kwargs["instance"] = self.object
608 try:
609 self.data = self.model.fetch_from(self.uri)
610 kwargs["data"] = self.data
611 except ImproperlyConfigured as e:
612 messages.error(self.request, e)
613 return kwargs
615 def form_valid(self, form):
616 """
617 Go through all the form fields and extract the ones that
618 start with `update_` and that are set (those are the checkboxes that
619 select which fields to update).
620 Create a dict from those values, add the uri and pass the dict on to
621 the models `import_data` method.
622 """
623 data = {}
624 for key, values in self.request.POST.items():
625 if key.startswith("update_"):
626 key = key.removeprefix("update_")
627 data[key] = self.data[key]
628 data["same_as"] = [self.uri] + data.get("same_as", [])
629 if data:
630 errors = self.object.import_data(data)
631 for field, error in errors.items():
632 messages.error(self.request, f"Could not update {field}: {error}")
633 messages.info(self.request, f"Updated fields {data.keys()}")
634 return super().form_valid(form)
636 def get_success_url(self):
637 return self.object.get_absolute_url()