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