Skip to content

Commit

Permalink
fix(generic): use import_string instead of custom method
Browse files Browse the repository at this point in the history
Django already comes with a util method to load a Python module member
from a string - `django.utils.module_loading.import_string`. Using this
instead of our own implementation in
`generic.utils.helper.class_from_path` we can refactor the lookup logic
and provide a more flexible way of looking up Python members.

Closes: #697
  • Loading branch information
b1rger committed Mar 18, 2024
1 parent 58e6c25 commit 591b1ca
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 80 deletions.
21 changes: 12 additions & 9 deletions apis_core/generic/api_views.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
from rest_framework import viewsets
from .serializers import serializer_factory, GenericHyperlinkedModelSerializer
from .helpers import first_match_via_mro
from .helpers import module_paths, first_member_match


class ModelViewSet(viewsets.ModelViewSet):
"""
API ViewSet for a generic model.
The queryset is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
The serializer class is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
"""

def dispatch(self, *args, **kwargs):
self.model = kwargs.get("contenttype").model_class()
return super().dispatch(*args, **kwargs)

def get_queryset(self):
queryset = first_match_via_mro(
queryset_methods = module_paths(
self.model, path="querysets", suffix="ViewSetQueryset"
) or (lambda x: x)
)
queryset = first_member_match(queryset_methods) or (lambda x: x)
return queryset(self.model.objects.all())

def get_serializer_class(self):
renderer = self.request.accepted_renderer
serializer_class = (
first_match_via_mro(self.model, path="serializers", suffix="Serializer")
or getattr(renderer, "serializer", None)
or GenericHyperlinkedModelSerializer
serializer_class_modules = module_paths(
self.model, path="serializers", suffix="Serializer"
)
serializer_class = first_member_match(
serializer_class_modules,
getattr(renderer, "serializer", GenericHyperlinkedModelSerializer),
)
return serializer_factory(self.model, serializer=serializer_class)
58 changes: 24 additions & 34 deletions apis_core/generic/helpers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import functools
import inspect
import importlib
import logging

from django.db.models import CharField, TextField, Q, Model
from django.contrib.auth import get_permission_codename
from django.utils import module_loading

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,42 +61,33 @@ def template_names_via_mro(model, suffix=""):


@functools.lru_cache
def class_from_path(classpath):
"""
Lookup if the class in `classpath` exists - if so return it,
otherwise return False
"""
module, cls = classpath.rsplit(".", 1)
try:
members = inspect.getmembers(importlib.import_module(module))
members = list(filter(lambda c: c[0] == cls, members))
except ModuleNotFoundError:
return False
if members:
return members[0]
return False
def permission_fullname(action: str, model: object) -> str:
permission_codename = get_permission_codename(action, model._meta)
return f"{model._meta.app_label}.{permission_codename}"


@functools.lru_cache
def first_match_via_mro(model, path: str = "", suffix: str = ""):
"""
Based on the MRO of a Django model, look for classes based on a
lookup algorithm and return the first one that matches.
"""
def module_paths(model, path: str = "", suffix: str = "") -> list:
paths = list(map(lambda x: x[:-1] + [path] + x[-1:], mro_paths(model)))
classes = [".".join(prefix) + suffix for prefix in paths]
name, res = next(filter(bool, map(class_from_path, classes)), ("nothing", None))
logger.debug(
"first_match_via_mro: found %s for %s, path: `%s`, suffix: `%s`",
name,
model,
path,
suffix,
)
return res
classes = tuple(".".join(prefix) + suffix for prefix in paths)
return classes


@functools.lru_cache
def permission_fullname(action: str, model: object) -> str:
permission_codename = get_permission_codename(action, model._meta)
return f"{model._meta.app_label}.{permission_codename}"
def import_string(dotted_path):
try:
return module_loading.import_string(dotted_path)
except (ModuleNotFoundError, ImportError):
return False


@functools.lru_cache
def first_member_match(dotted_path_list: tuple[str], fallback=None) -> object:
logger.debug("Looking for matching class in %s", dotted_path_list)
pathgen = map(import_string, dotted_path_list)
result = next(filter(bool, pathgen), None)
if result:
logger.debug("Found matching attribute/class in %s", result)
else:
logger.debug("Found nothing, returning fallback: %s", fallback)
return result or fallback
58 changes: 27 additions & 31 deletions apis_core/generic/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
from .filtersets import filterset_factory, GenericFilterSet
from .forms import GenericModelForm, GenericImportForm
from .helpers import (
first_match_via_mro,
template_names_via_mro,
generate_search_filter,
permission_fullname,
module_paths,
first_member_match,
)
from apis_core.utils.helpers import create_object_from_uri

Expand Down Expand Up @@ -85,21 +86,19 @@ class List(
Access requires the `<model>_view` permission.
It is based on django-filters FilterView and django-tables SingleTableMixin.
The table class is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
The filterset class is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
The queryset is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
"""

template_name_suffix = "_list"
permission_action_required = "view"

def get_table_class(self):
table_class = (
first_match_via_mro(self.model, path="tables", suffix="Table")
or GenericTable
)
table_modules = module_paths(self.model, path="tables", suffix="Table")
table_class = first_member_match(table_modules, GenericTable)
return table_factory(self.model, table_class)

def get_table_kwargs(self):
Expand All @@ -115,16 +114,17 @@ def get_table_kwargs(self):
return kwargs

def get_filterset_class(self):
filterset_class = (
first_match_via_mro(self.model, path="filtersets", suffix="FilterSet")
or GenericFilterSet
filterset_modules = module_paths(
self.model, path="filtersets", suffix="FilterSet"
)
filterset_class = first_member_match(filterset_modules, GenericFilterSet)
return filterset_factory(self.model, filterset_class)

def get_queryset(self):
queryset = first_match_via_mro(
queryset_methods = module_paths(
self.model, path="querysets", suffix="ListViewQueryset"
) or (lambda x: x)
)
queryset = first_member_match(queryset_methods) or (lambda x: x)
return self.filter_queryset(queryset(self.model.objects.all()))


Expand All @@ -142,17 +142,15 @@ class Create(GenericModelMixin, PermissionRequiredMixin, CreateView):
Create view for a generic model.
Access requires the `<model>_add` permission.
The form class is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
"""

template_name = "generic/generic_form.html"
permission_action_required = "add"

def get_form_class(self):
form_class = (
first_match_via_mro(self.model, path="forms", suffix="Form")
or GenericModelForm
)
form_modules = module_paths(self.model, path="forms", suffix="Form")
form_class = first_member_match(form_modules, GenericModelForm)
return modelform_factory(self.model, form_class)

def get_success_url(self):
Expand Down Expand Up @@ -189,16 +187,14 @@ class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView):
Update view for a generic model.
Access requires the `<model>_change` permission.
The form class is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
"""

permission_action_required = "change"

def get_form_class(self):
form_class = (
first_match_via_mro(self.model, path="forms", suffix="Form")
or GenericModelForm
)
form_modules = module_paths(self.model, path="forms", suffix="Form")
form_class = first_member_match(form_modules, GenericModelForm)
return modelform_factory(self.model, form_class)

def get_success_url(self):
Expand All @@ -212,7 +208,7 @@ class Autocomplete(
Autocomplete view for a generic model.
Access requires the `<model>_view` permission.
The queryset is overridden by the first match from
the `first_match_via_mro` helper.
the `first_member_match` helper.
"""

permission_action_required = "view"
Expand All @@ -228,19 +224,21 @@ def setup(self, *args, **kwargs):
self.template = None

def get_queryset(self):
queryset = first_match_via_mro(
queryset_methods = module_paths(
self.model, path="querysets", suffix="AutocompleteQueryset"
)
queryset = first_member_match(queryset_methods)
if queryset:
return queryset(self.model, self.q)
return self.model.objects.filter(generate_search_filter(self.model, self.q))

def get_results(self, context):
external_only = self.kwargs.get("external_only", False)
results = [] if external_only else super().get_results(context)
ExternalAutocomplete = first_match_via_mro(
self.model, path="querysets", suffix="ExternalAutocomplete"
queryset_methods = module_paths(
self.model, paths="querysets", suffix="ExternalAutocomplete"
)
ExternalAutocomplete = first_member_match(queryset_methods)
if ExternalAutocomplete:
results.extend(ExternalAutocomplete().get_results(self.q))
return results
Expand All @@ -255,10 +253,8 @@ class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
permission_action_required = "create"

def get_form_class(self):
form_class = (
first_match_via_mro(self.model, path="forms", suffix="ImportForm")
or GenericImportForm
)
form_modules = module_paths(self.model, paths="forms", suffix="ImportForm")
form_class = first_member_match(form_modules, GenericImportForm)
return modelform_factory(self.model, form_class)

def form_valid(self, form):
Expand Down
9 changes: 3 additions & 6 deletions apis_core/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from apis_core.utils.settings import get_entity_settings_by_modelname
from apis_core.apis_relations.tables import get_generic_triple_table
from apis_core.apis_metainfo.models import Uri
from apis_core.generic.helpers import first_match_via_mro
from apis_core.generic.helpers import module_paths, first_member_match

from django.apps import apps
from django.db import DEFAULT_DB_ALIAS, router
Expand Down Expand Up @@ -190,11 +190,8 @@ def create_object_from_uri(uri: str, model: object) -> object:
uri = Uri.objects.get(uri=uri)
return uri.root_object
except Uri.DoesNotExist:
Importer = first_match_via_mro(
model,
path="importers",
suffix="Importer",
)
importer_paths = module_paths(model, path="importers", suffix="Importer")
Importer = first_member_match(importer_paths)
if Importer is not None:
importer = Importer(uri, model)
instance = importer.create_instance()
Expand Down

0 comments on commit 591b1ca

Please sign in to comment.