Coverage for apis_core/generic/views.py: 84%
308 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-17 09:41 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-17 09:41 +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.shortcuts import get_object_or_404, redirect
18from django.template.exceptions import TemplateDoesNotExist
19from django.template.loader import select_template
20from django.urls import reverse
21from django.views import View
22from django.views.generic import DetailView
23from django.views.generic.base import TemplateView
24from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
25from django_filters.filterset import filterset_factory
26from django_filters.views import FilterView
27from django_tables2 import SingleTableMixin
28from django_tables2.columns import library
29from django_tables2.export.views import ExportMixin
30from django_tables2.tables import table_factory
32from apis_core.uris.models import Uri
33from apis_core.uris.utils import create_object_from_uri
34from apis_core.utils.helpers import get_importer_for_model
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)
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 # we look at the selected columns and exclude
145 # all modelfields that are not part of that list
146 form = self.get_filterset(self.get_filterset_class()).form
147 initial = form.fields["columns"].initial if "columns" in form.fields else []
148 selected_columns = self.request.GET.getlist("columns", initial)
149 modelfields = self.model._meta.get_fields()
150 kwargs["exclude"] = [
151 field.name for field in modelfields if field.name not in selected_columns
152 ]
154 # now we look at the selected columns and
155 # add all modelfields and annotated fields that
156 # are part of the selected columns to the extra_columns
157 annotationfields = list()
158 for key, value in self.object_list.query.annotations.items():
159 # we have to use copy, so we don't edit the original field
160 fake_field = copy(getattr(value, "field", value.output_field))
161 setattr(fake_field, "name", key)
162 annotationfields.append(fake_field)
163 extra_fields = list(
164 filter(
165 lambda x: x.name in selected_columns,
166 modelfields + tuple(annotationfields),
167 )
168 )
169 kwargs["extra_columns"] = [
170 (field.name, library.column_for_field(field, accessor=field.name))
171 for field in extra_fields
172 if field.name not in self.get_table_class().base_columns
173 ]
175 return kwargs
177 def get_filterset_class(self):
178 filterset_modules = module_paths(
179 self.model, path="filtersets", suffix="FilterSet"
180 )
181 filterset_class = first_member_match(filterset_modules, GenericFilterSet)
182 return filterset_factory(self.model, filterset_class)
184 def _get_columns_choices(self, columns_exclude):
185 # we start with the model fields
186 choices = [
187 (field.name, pretty_name(getattr(field, "verbose_name", field.name)))
188 for field in self.model._meta.get_fields()
189 if not getattr(field, "auto_created", False)
190 and not isinstance(field, ManyToManyRel)
191 ]
192 # we add any annotated fields to that
193 choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
194 # now we drop all the choices that are listed in columns_exclude
195 choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
196 return choices
198 def _get_columns_initial(self, columns_exclude):
199 return [
200 field
201 for field in self.get_table().columns.names()
202 if field not in columns_exclude
203 ]
205 def get_filterset(self, filterset_class):
206 """
207 We override the `get_filterset` method, so we can inject a
208 `columns` selector into the form
209 """
210 filterset = super().get_filterset(filterset_class)
211 columns_exclude = filterset.form.columns_exclude
213 # we inject a `columns` selector in the beginning of the form
214 if choices := self._get_columns_choices(columns_exclude):
215 columns = forms.MultipleChoiceField(
216 required=False,
217 choices=choices,
218 initial=self._get_columns_initial(columns_exclude),
219 )
220 filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
221 # rebuild the layout, now that the columns field was added
222 filterset.form.helper.layout = filterset.form.helper.build_default_layout(
223 filterset.form
224 )
225 # If the filterset form contains form data
226 # we add a CSS class to the element wrapping
227 # that field in HTML. This CSS class can be
228 # used to emphasize the fields that are used.
229 # To be able to compare the fields with the form
230 # data, we create a temporary mapping between
231 # widget_names and fields
232 fields = {}
233 for name, field in filterset.form.fields.items():
234 fields[name] = name
235 if hasattr(field.widget, "widgets_names"):
236 for widget_name in field.widget.widgets_names:
237 fields[name + widget_name] = name
238 if data := filterset.form.data:
239 for param in [param for param, value in data.items() if value]:
240 if fieldname := fields.get(param, None):
241 filterset.form.helper[fieldname].wrap(
242 Field, wrapper_class="filter-input-selected"
243 )
245 return filterset
247 def get_queryset(self):
248 queryset_methods = module_paths(
249 self.model, path="querysets", suffix="ListViewQueryset"
250 )
251 queryset = first_member_match(queryset_methods) or (lambda x: x)
252 return queryset(self.model.objects.all())
254 def get_table_pagination(self, table):
255 """
256 Override `get_table_pagination` from the tables2 TableMixinBase,
257 so we can set the table_pagination value as attribute of the table.
258 """
259 self.table_pagination = getattr(table, "table_pagination", None)
260 return super().get_table_pagination(table)
263class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
264 """
265 Detail view for a generic model.
266 Access requires the `<model>_view` permission.
267 """
269 permission_action_required = "view"
272class Create(
273 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
274):
275 """
276 Create view for a generic model.
277 Access requires the `<model>_add` permission.
278 The form class is overridden by the first match from
279 the `first_member_match` helper.
280 """
282 template_name_suffix = "_create"
283 permission_action_required = "add"
285 def get_form_class(self):
286 form_modules = module_paths(self.model, path="forms", suffix="Form")
287 form_class = first_member_match(form_modules, GenericModelForm)
288 return modelform_factory(self.model, form_class)
290 def get_success_message(self, cleaned_data):
291 message_templates = template_names_via_mro(
292 self.model, "_create_success_message.html"
293 )
294 template = select_template(message_templates)
295 return template.render({"object": self.object})
297 def get_success_url(self):
298 return self.object.get_create_success_url()
301class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
302 """
303 Delete view for a generic model.
304 Access requires the `<model>_delete` permission.
305 """
307 permission_action_required = "delete"
309 def get_success_url(self):
310 if redirect := self.request.GET.get("redirect"):
311 return redirect
312 return reverse(
313 "apis_core:generic:list",
314 args=[self.request.resolver_match.kwargs["contenttype"]],
315 )
318class Update(
319 GenericModelMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView
320):
321 """
322 Update view for a generic model.
323 Access requires the `<model>_change` permission.
324 The form class is overridden by the first match from
325 the `first_member_match` helper.
326 """
328 permission_action_required = "change"
330 def get_form_class(self):
331 form_modules = module_paths(self.model, path="forms", suffix="Form")
332 form_class = first_member_match(form_modules, GenericModelForm)
333 return modelform_factory(self.model, form_class)
335 def get_success_message(self, cleaned_data):
336 message_templates = template_names_via_mro(
337 self.model, "_update_success_message.html"
338 )
339 template = select_template(message_templates)
340 return template.render({"object": self.object})
342 def get_success_url(self):
343 return self.object.get_update_success_url()
346class Duplicate(GenericModelMixin, PermissionRequiredMixin, View):
347 permission_action_required = "add"
349 def get(self, request, *args, **kwargs):
350 source_obj = get_object_or_404(self.model, pk=kwargs["pk"])
351 newobj = source_obj.duplicate()
353 message_templates = template_names_via_mro(
354 self.model, "_duplicate_success_message.html"
355 )
356 template = select_template(message_templates)
357 messages.success(request, template.render({"object": source_obj}))
358 return redirect(newobj.get_edit_url())
361class Autocomplete(
362 GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
363):
364 """
365 Autocomplete view for a generic model.
366 Access requires the `<model>_view` permission.
367 The queryset is overridden by the first match from
368 the `first_member_match` helper.
369 """
371 permission_action_required = "view"
372 template_name_suffix = "_autocomplete_result"
374 def setup(self, *args, **kwargs):
375 super().setup(*args, **kwargs)
376 # We use a URI parameter to enable the create functionality in the
377 # autocomplete dropdown. It is not important what the value of the
378 # `create_field` is, because we use create_object_from_uri anyway.
379 self.create_field = self.request.GET.get("create", None)
380 try:
381 template = select_template(self.get_template_names())
382 self.template = template.template.name
383 except TemplateDoesNotExist:
384 self.template = None
386 def get_queryset(self):
387 queryset_methods = module_paths(
388 self.model, path="querysets", suffix="AutocompleteQueryset"
389 )
390 queryset = first_member_match(queryset_methods)
391 if queryset:
392 return queryset(self.model, self.q)
393 return self.model.objects.filter(generate_search_filter(self.model, self.q))
395 def get_results(self, context):
396 external_only = self.kwargs.get("external_only", False)
397 results = [] if external_only else super().get_results(context)
398 queryset_methods = module_paths(
399 self.model, path="querysets", suffix="ExternalAutocomplete"
400 )
401 ExternalAutocomplete = first_member_match(queryset_methods)
402 if ExternalAutocomplete:
403 results.extend(ExternalAutocomplete().get_results(self.q))
404 return results
406 def create_object(self, value):
407 """
408 We try multiple approaches to create a model instance from a value:
409 * we first test if the value is an URL and if so we expect it to be
410 something that can be imported using one of the configured importers
411 and so we pass the value to the import logic.
412 * if the value is not a string, we try to pass it to the `create_from_string`
413 method of the model, if that does exist. Its the models responsibility to
414 implement this method and the method should somehow know how to create
415 model instance from the value...
416 * finally we pass the value to the `create_object` method from the DAL
417 view, which tries to pass it to `get_or_create` which likely also fails,
418 but this is expected and we raise a more useful exception.
419 """
420 try:
421 URLValidator()(value)
422 return create_object_from_uri(
423 value, self.queryset.model, raise_on_fail=True
424 )
425 except ValidationError:
426 pass
427 try:
428 return self.queryset.model.create_from_string(value)
429 except AttributeError:
430 raise ImproperlyConfigured(
431 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string'
432 )
434 def post(self, request, *args, **kwargs):
435 try:
436 return super().post(request, *args, **kwargs)
437 except Exception as e:
438 return http.JsonResponse({"error": str(e)})
441class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
442 template_name_suffix = "_import"
443 permission_action_required = "add"
445 def get_form_class(self):
446 form_modules = module_paths(self.model, path="forms", suffix="ImportForm")
447 form_class = first_member_match(form_modules, GenericImportForm)
448 return modelform_factory(self.model, form_class)
450 def form_valid(self, form):
451 self.object = form.cleaned_data["url"]
452 return super().form_valid(form)
454 def get_success_url(self):
455 return self.object.get_absolute_url()
458class SelectMergeOrEnrich(GenericModelMixin, PermissionRequiredMixin, FormView):
459 """
460 This view provides a simple form that allows to select other entities (also from
461 external sources, if set up) and on form submit redirects to the Enrich view.
462 """
464 template_name_suffix = "_selectmergeorenrich"
465 permission_action_required = "add"
466 form_class = GenericSelectMergeOrEnrichForm
468 def get_object(self, *args, **kwargs):
469 return get_object_or_404(self.model, pk=self.kwargs.get("pk"))
471 def get_context_data(self, *args, **kwargs):
472 context = super().get_context_data(*args, **kwargs)
473 context["object"] = self.get_object()
474 return context
476 def get_form_kwargs(self, *args, **kwargs):
477 kwargs = super().get_form_kwargs(*args, **kwargs)
478 kwargs["instance"] = self.get_object()
479 return kwargs
482class MergeWith(GenericModelMixin, PermissionRequiredMixin, FormView):
483 """
484 Generic merge view.
485 """
487 permission_action_required = "change"
488 form_class = GenericMergeWithForm
489 template_name_suffix = "_merge"
491 def setup(self, *args, **kwargs):
492 super().setup(*args, **kwargs)
493 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
494 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"])
496 def get_context_data(self, **kwargs):
497 """
498 The context consists of the two objects that are merged as well
499 as a list of changes. Those changes are presented in the view as
500 a table with diffs
501 """
502 Change = namedtuple("Change", "field old new")
503 ctx = super().get_context_data(**kwargs)
504 ctx["changes"] = []
505 for field in self.object._meta.fields:
506 newval = self.object.get_field_value_after_merge(self.other, field)
507 ctx["changes"].append(
508 Change(field.verbose_name, getattr(self.object, field.name), newval)
509 )
510 ctx["object"] = self.object
511 ctx["other"] = self.other
512 return ctx
514 def form_valid(self, form):
515 self.object.merge_with([self.other])
516 messages.info(self.request, f"Merged values of {self.other} into {self.object}")
517 return super().form_valid(form)
519 def get_success_url(self):
520 return self.object.get_absolute_url()
523class Enrich(GenericModelMixin, PermissionRequiredMixin, FormView):
524 """
525 Enrich an entity with data from an external source
526 If so, it uses the proper Importer to get the data from the Uri and
527 provides the user with a form to select the fields that should be updated.
528 """
530 permission_action_required = "change"
531 template_name_suffix = "_enrich"
532 form_class = GenericEnrichForm
533 importer_class = None
535 def setup(self, *args, **kwargs):
536 super().setup(*args, **kwargs)
537 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"])
538 self.uri = self.request.GET.get("uri")
539 if not self.uri:
540 messages.error(self.request, "No uri parameter specified.")
541 self.importer_class = get_importer_for_model(self.model)
543 def get(self, *args, **kwargs):
544 if self.uri.isdigit():
545 return redirect(self.object.get_merge_url(self.uri))
546 try:
547 uriobj = Uri.objects.get(uri=self.uri)
548 if uriobj.object_id != self.object.id:
549 messages.info(
550 self.request,
551 f"Object with URI {self.uri} already exists, you were redirected to the merge form.",
552 )
553 return redirect(self.object.get_merge_url(uriobj.object_id))
554 except Uri.DoesNotExist:
555 pass
556 return super().get(*args, **kwargs)
558 def get_context_data(self, **kwargs):
559 ctx = super().get_context_data(**kwargs)
560 ctx["object"] = self.object
561 ctx["uri"] = self.uri
562 return ctx
564 def get_form_kwargs(self, *args, **kwargs):
565 kwargs = super().get_form_kwargs(*args, **kwargs)
566 kwargs["instance"] = self.object
567 try:
568 importer = self.importer_class(self.uri, self.model)
569 kwargs["data"] = importer.get_data()
570 except ImproperlyConfigured as e:
571 messages.error(self.request, e)
572 return kwargs
574 def form_valid(self, form):
575 """
576 Go through all the form fields and extract the ones that
577 start with `update_` and that are set (those are the checkboxes that
578 select which fields to update).
579 Then use the importers `import_into_instance` method to set those
580 fields values on the model instance.
581 """
582 update_fields = [
583 key.removeprefix("update_")
584 for (key, value) in self.request.POST.items()
585 if key.startswith("update_") and value
586 ]
587 importer = self.importer_class(self.uri, self.model)
588 importer.import_into_instance(self.object, fields=update_fields)
589 messages.info(self.request, f"Updated fields {update_fields}")
590 content_type = ContentType.objects.get_for_model(self.model)
591 uri, created = Uri.objects.get_or_create(
592 uri=importer.get_uri,
593 content_type=content_type,
594 object_id=self.object.id,
595 )
596 if created:
597 messages.info(self.request, f"Added uri {self.uri} to {self.object}")
598 return super().form_valid(form)
600 def get_success_url(self):
601 return self.object.get_absolute_url()