Skip to content

Instantly share code, notes, and snippets.

@vdboor
Last active September 29, 2019 03:11
Show Gist options
  • Save vdboor/7f7865df748ed8949ba5 to your computer and use it in GitHub Desktop.
Save vdboor/7f7865df748ed8949ba5 to your computer and use it in GitHub Desktop.
A view mixin that allows fetching a parent object, and limiting the children for that. A typical example would be "users belonging to a customer" or "entries in a category".
"""
Parent object mixin, also available at: https://gist.github.com/vdboor/7f7865df748ed8949ba5
"""
from functools import lru_cache
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
from django.db import models
from django.http import Http404
from django.urls import reverse
class ParentObjectMixin(object):
"""
A variation of Django's :class:`~django.views.generic.detail.SingleObjectMixin` that
provides a "parent" context for a listing of subobjects. Example usage:
.. code-block:: python
# views.py:
class UserListView(ParentObjectMixin, ListView):
model = User
parent_model = Customer
parent_related_name = 'customer'
class UserDetailView(ParentObjectMixin, DetailView):
model = User
parent_model = Customer
parent_related_name = 'customer'
# urls.py:
url(r'customers/(?P<parent_pk>\d+)/users/', UserListView.as_view()),
url(r'customers/(?P<parent_pk>\d+)/users/(?P<pk>\d)/', UserDetailView.as_view()),
You can also override :meth:`get_parent_object` and choose a different implementation.
For example, the "parent object" could be retrieved from the session.
By applying this mixin everywhere, the chance of exposing too many objects is reduced.
Thus, it might add in learning curve complexity (more inheritance levels),
but reduces security risks. Applying a base class everywhere is easier to test
then updating ``get_queryset()`` everywhere to reduce exposed objects.
"""
#: Whether the *pk* and *slug* fields can be omitted to work without filtering.
parent_optional = False
#: The parent model to filter on.
parent_model = None
#: The *slug* field that the parent model uses.
parent_slug_field = 'slug'
#: The context variable to add to the template
parent_context_object_name = None
#: The name of the field in the URL pattern that represents the *slug*.
parent_slug_url_kwarg = 'parent_slug'
#: The name of the field in the URL pattern that represents the *pk*.
parent_pk_url_kwarg = 'parent_pk'
#: The related field name, used by the queryset of the child model to filter on the parent.
parent_related_name = None
def dispatch(self, request, *args, **kwargs):
"""
Make self.parent_object available for all calls.
Please note that this might be too early if this mixin is included in applications.
For example, a base view might want to perform a permission check before fetching objects.
"""
self.parent_object = self.get_parent_object()
return super(ParentObjectMixin, self).dispatch(request, *args, **kwargs)
def get_parent_queryset(self):
"""
The processing function to get the parent queryset
similar so Django's :meth:`~django.views.generic.detail.SingleObjectMixin.get_queryset`.
"""
return self.parent_model.objects.all()
def get_parent_object(self):
"""
The processing function to get the parent object,
similar so Django's :meth:`~django.views.generic.detail.SingleObjectMixin.get_object`.
"""
pk = self.kwargs.get(self.parent_pk_url_kwarg, None)
slug = self.kwargs.get(self.parent_slug_url_kwarg, None)
if pk is None and slug is None:
if self.parent_optional:
# If not provided in url patters, the view can ignore the parent
return None
else:
raise ImproperlyConfigured("No '{0}' or kwarg provided in URL pattern!".format(
self.parent_pk_url_kwarg
))
queryset = self.get_parent_queryset()
if pk is not None:
queryset = queryset.filter(pk=pk)
# Next, try looking up by slug.
elif slug is not None:
slug_field = self.get_parent_slug_field()
queryset = queryset.filter(**{slug_field: slug})
try:
return queryset.get()
except ObjectDoesNotExist as e:
raise Http404(e)
def get_queryset(self):
"""
Auto filter the child queryset on parent object.
This also avoids hacking via /parent/OTHER_ID/child/ID/ mixups
"""
queryset = super(ParentObjectMixin, self).get_queryset()
rel_name = self.get_parent_related_name()
if not rel_name or (self.parent_optional and self.parent_object is None):
return queryset
else:
return self.limit_queryset_to_parent_object(queryset)
def limit_queryset_to_parent_object(self, queryset):
"""
Filter the queryset to the parent model.
"""
filter = {self.get_parent_related_name(): self.parent_object}
return queryset.filter(**filter)
def get_parent_slug_field(self):
"""
Get the name of a slug field to be used to look up by slug.
"""
return self.parent_slug_field
def get_parent_related_name(self):
"""
Return the related name that connects the child and parent model.
"""
if self.parent_related_name:
return self.parent_related_name
else:
# Auto detect the possible relation!
return _get_foreign_key(self.parent_model, self.model).name
def get_parent_context_object_name(self, obj):
"""
Get the name to use for the parent object in the template.
"""
if self.parent_context_object_name:
return self.parent_context_object_name
elif isinstance(obj, models.Model):
return obj._meta.model_name
else:
return None
def get_context_data(self, **kwargs):
"""
Include ``parent_object`` in the template context.
"""
context = super(ParentObjectMixin, self).get_context_data(**kwargs)
if self.parent_object is not None:
context['parent_object'] = self.parent_object
context_object_name = self.get_parent_context_object_name(self.parent_object)
if context_object_name:
context[context_object_name] = self.parent_object
return context
def get_parent_url_kwargs(self):
"""
Return the extra optional parent kwargs, if they were part of this URL.
"""
kwargs = {}
# Only include the kwargs that are part of the current URL
if self.parent_pk_url_kwarg in self.kwargs:
kwargs[self.parent_pk_url_kwarg] = self.kwargs[self.parent_pk_url_kwarg]
elif self.parent_slug_url_kwarg in self.kwargs:
kwargs[self.parent_slug_url_kwarg] = self.kwargs[self.parent_slug_url_kwarg]
return kwargs
def reverse(self, view_name, args=None, kwargs=None):
"""
Resolve an URL, and preserve the optional parent object kwargs if they were provided.
"""
kwargs = kwargs or {}
kwargs.update(self.get_parent_url_kwargs())
try:
current_app = self.request.current_app
except AttributeError:
try:
current_app = self.request.resolver_match.namespace
except AttributeError:
current_app = None
return reverse(view_name, args=args, kwargs=kwargs, current_app=current_app)
@lru_cache()
def _get_foreign_key(parent_model, model):
"""
Copied and simplified from ``django.forms.models._get_foreign_key()``
"""
# Try to discover what the ForeignKey from model to parent_model is
fks_to_parent = [
f for f in model._meta.fields
if isinstance(f, models.ForeignKey) and (
f.remote_field.model == parent_model or
f.remote_field.model in parent_model._meta.get_parent_list()
)
]
if len(fks_to_parent) == 1:
return fks_to_parent[0]
elif len(fks_to_parent) == 0:
raise ValueError(
"'{0}.{1}' has no ForeignKey to '{2}.{3}'.".format(
model._meta.app_label, model._meta.object_name,
parent_model._meta.app_label, parent_model._meta.object_name
)
)
else:
raise ValueError(
"'{0}.{1}' has more than one ForeignKey to '{2}.{3}'.".format(
model._meta.app_label, model._meta.object_name,
parent_model._meta.app_label, parent_model._meta.object_name
)
)
import logging
from django.template import Library, TemplateSyntaxError
register = Library()
logger = logging.getLogger(__name__)
@register.simple_tag(takes_context=True)
def purl(context, view_name, *args, **kwargs):
"""
This is a variation of the ``{% url .. %}`` tag,
that makes sure a parent object ID is inserted in the URL.
.. code-block:: html+django
{% purl 'dashboard_index' %}
{% purl 'person_detail' pk=person.pk %}
"""
view = context.get('view')
if view is None:
# Calls to render() outside class based views:
caller_view = context['request'].resolver_match.view_name
logger.warning("{%% purl '%s' .. %%} can't resolve from function based view (%s)", view_name, caller_view)
return reverse(view_name, args=args, kwargs=kwargs)
if not hasattr(view, 'reverse'):
cls_name = view.__class__.__name__
raise TemplateSyntaxError(f"Can only use purl on ParentObjectMixin views, not {cls_name}!")
return view.reverse(view_name, args=args, kwargs=kwargs)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment