# Imports
from django.core.exceptions import ImproperlyConfigured
from django.core.paginator import Paginator, InvalidPage
from django.forms import models as model_forms
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse, NoReverseMatch
from django.utils.translation import ugettext_lazy as _
import os
from superdjango.exceptions import IMustBeMissingSomething
from .base import BaseView
from .forms import FormMixin
# Exports
__all__ = (
"CreateView",
"DeleteView",
"DetailView",
"ListView",
"ModelView",
"UpdateView",
)
# Base
[docs]class ModelView(FormMixin, BaseView):
"""Base class for model interactions.
To avoid the excessive use of mixins, an engineering compromise was struck when developing the ``ModelView``:
1. The view extends the base view which includes a number of commonly useful features and context variables.
2. The view also extends :py:class:`FormMixin`, which doesn't make sense for detail and list views, but provides the
basis for sensible defaults with create and update views.
3. The delete view may be customized to include a form if one is desired. For example, to add a checkbox for an
additional confirmation step.
4. Other than ``get_object_list()`` and use of ``get_queryset()``, ``ModelView`` does *not* implement list logic.
:py:class:`ListView` provides this instead.
5. No authentication or authorization logic is provided by default. It is up to the developer to utilize
``superdjango.views.access`` or their own custom logic if these features are desired.
"""
context_object_name = None
"""The name of the model object or objects when used in a template."""
field_names = None
"""A list of field names to be included in the view. For form views, these are the names included in the form. For
detail and list views, these may be used to display record data.
"""
lookup_field = "pk"
"""The field used to uniquely identify the model."""
lookup_key = None
"""The key in GET used to uniquely identify the model. Defaults to the ``lookup_field``."""
model = None
"""The model (class) to which the view pertains. Required."""
object = None
"""The record (instance) of a model for create, detail, delete, and update views. This is not used by list views."""
object_list = None
"""The record instances of a model for list views."""
prefetch_related = None
"""A list of foreign key or many to many fields that should be called using Django's
`prefetch_related <https://docs.djangoproject.com/en/stable/ref/models/querysets/#prefetch-related>`_.
"""
queryset = None
"""The queryset used to acquire the object or object list. See ``get_queryset()``."""
select_related = None
"""A list of foreign key fields that should be called using Django's
`select_related <https://docs.djangoproject.com/en/stable/ref/models/querysets/#select-related>`_.
"""
template_name_suffix = None
"""The suffix added to the end of template names when automatically generated. This should be overridden by child
classes.
"""
[docs] @classmethod
def get_app_label(cls):
"""Get the app label (name) for the model represented by the view.
:rtype: str
"""
# noinspection PyProtectedMember
return cls.get_model()._meta.app_label
[docs] def get_cancel_url(self):
"""Return the value of the class attribute, if defined. Otherwise attempt to return the list view for the
current model.
:rtype: str | None
"""
if self.cancel_url is not None:
return self.cancel_url
view_name = "%s_%s_list" % (self.get_app_label(), self.get_model_name())
try:
return reverse(view_name)
except NoReverseMatch:
return None
[docs] def get_context_data(self, **kwargs):
"""Get context used to render the response.
The following variables are added to the context:
- ``verbose_name``: The verbose name of the model.
- ``verbose_name_plural``: The plural name of the model.
For single-object views, the ``object`` variable is also added along with a specific name if
``context_object_name`` is defined.
For multi-object views, the ``object_list`` variable is added along with a specific name if
``context_object_name`` is defined.
"""
context = super().get_context_data(**kwargs)
context_object_name = self.get_context_object_name()
if self.object is not None:
context['object'] = self.object
if context_object_name is not None:
context[context_object_name] = self.object
elif self.object_list is not None:
context['object_list'] = self.object_list
if context_object_name is not None:
context[context_object_name] = self.object_list
else:
pass
context['verbose_name'] = self.get_verbose_name()
context['verbose_name_plural'] = self.get_verbose_name_plural()
return context
[docs] def get_context_object_name(self):
"""Get the name of the model (or models) used in templates.
:rtype: str | None
"""
if self.context_object_name is not None:
return self.context_object_name
return None
[docs] def get_field_names(self):
"""Get the field names to be included in the model form.
:rtype: list[str]
.. tip::
If the ``field_names`` class attribute is not defined, *all* of the model's fields are included, which is
probably *not* what you want.
"""
if self.field_names is not None:
return self.field_names
field_names = list()
model = self.get_model()
# noinspection PyProtectedMember
for field in model._meta.get_fields():
field_names.append(field.name)
return field_names
[docs] @classmethod
def get_lookup_field(cls):
"""Get the name of the field used to uniquely identify a model instance.
:rtype: str
:raises: IMustBeMissingSomething
"""
if cls.lookup_field is not None:
return cls.lookup_field
raise IMustBeMissingSomething(cls.__name__, "lookup_field", "get_lookup_field")
[docs] @classmethod
def get_lookup_key(cls):
"""Get the key used in GET to uniquely identify a model instance.
:rtype: str
"""
if cls.lookup_key is not None:
return cls.lookup_key
return cls.get_lookup_field()
[docs] @classmethod
def get_model(cls):
"""Get the model class used by the view.
:raise: IMustBeMissingSomething
.. note::
This saves checking for the model in the various class methods rather than accessing ``cls.model`` and
handling the exception every time the model is referenced.
"""
if cls.model is not None:
return cls.model
raise IMustBeMissingSomething(cls.__name__, "model", "get_model")
[docs] @classmethod
def get_model_name(cls):
"""Get the model name in lower case.
:rtype: str
:raise: IMustBeMissingSomething
:raises: See ``get_model()``.
"""
model = cls.get_model()
return model.__name__.lower()
[docs] def get_object(self):
"""Get the object (model instance) that may be used in :py:class:`CreateView`, :py:class:`DetailView`, and
:py:class:`DeleteView`.
"""
lookup_field = self.get_lookup_field()
lookup_key = self.get_lookup_key()
queryset = self.get_queryset()
try:
# noinspection PyUnresolvedReferences
criteria = {lookup_field: self.kwargs[lookup_key]}
except KeyError:
e = 'The "%s" lookup key for the "%s" lookup field was not found in kwargs for the "%s" view.'
raise ImproperlyConfigured(e % (lookup_key, lookup_field, self.__class__.__name__))
return get_object_or_404(queryset, **criteria)
[docs] def get_object_list(self):
"""Get the objects to be displayed by list views.
:rtype: django.db.models.QuerySet
"""
return self.get_queryset()
[docs] def get_queryset(self):
"""Get the queryset used by the view. This will either be a list or individual instance.
:rtype: django.db.models.QuerySet
"""
if self.queryset is not None:
# noinspection PyProtectedMember
return self.queryset._clone()
model = self.get_model()
# noinspection PyProtectedMember
queryset = model._default_manager.all()
if isinstance(self.select_related, (list, tuple)):
queryset = queryset.select_related(*self.select_related)
if isinstance(self.prefetch_related, (list, tuple)):
queryset = queryset.prefetch_related(*self.prefetch_related)
return queryset
[docs] def get_success_url(self):
"""Return the value of the class attribute, if defined. Otherwise attempt to return the list view for the
current model. Alternatively, the absolute URL will be returned if the model defines ``get_absolute_url()``.
:raise: IMustBeMissingSomething
"""
if self.success_url is not None:
return self.success_url
view_name = "%s_%s_list" % (self.get_app_label(), self.get_model_name())
try:
return reverse(view_name)
except NoReverseMatch:
pass
try:
return self.object.get_absolute_url()
except AttributeError:
pass
raise IMustBeMissingSomething(self.__class__.__name__, "get_success_url")
[docs] def get_template_name_suffix(self):
"""Get the suffix for the current view template.
:rtype: str
.. tip::
Extending classes should define the ``template_name_suffix``. The suffix should include an underscore for
separation. For example, the suffix for a view for creating a new record would be ``_add``.
The default behavior here is to return an empty string if ``template_name_suffix`` is not defined.
"""
if self.template_name_suffix is not None:
return self.template_name_suffix
return ""
[docs] def get_template_names(self):
"""Get the template names that may be used for rendering the response.
:rtype: list[str]
The possible names are generated like so:
1. If the child class defines a ``template_name``, this is always returned as the first element of the list.
2. This method first defines templates that may be defined by the local project in the form of
``{app_label}/{model_name_lower}{template_name_suffix}.html``.
"""
templates = list()
# This will be the first template that is checked, which means if takes priority over any other possible
# template names.
if self.template_name is not None:
templates.append(self.template_name)
# The default template name is based on the suffix, which may supplied by the extending class.
template_name_suffix = self.get_template_name_suffix()
if template_name_suffix is not None:
file_name = "%s%s.html" % (self.get_model_name(), template_name_suffix)
# noinspection PyProtectedMember
templates.append(os.path.join(self.model._meta.app_label, file_name))
return templates
[docs] def get_verbose_name(self):
"""Get the verbose name for the model.
:rtype: str
"""
# noinspection PyProtectedMember
return self.get_model()._meta.verbose_name
[docs] def get_verbose_name_plural(self):
"""Get the plural verbose name for the model.
:rtype: str
"""
# noinspection PyProtectedMember
return self.get_model()._meta.verbose_name_plural
# Views
[docs]class CreateView(ModelView):
"""Present, validate, and submit a form for a new model record (instance).
.. note::
Because :py:class:`ModelView`` extends :py:class:`FormMixin`, this class does *not* need to implement ``get()``,
``get_form()``, ``form_invalid()``, or ``post()``.
See also ``ModelView.get_success_url()``.
"""
template_name_suffix = "_form"
[docs]class DeleteView(ModelView):
"""Delete and existing model record (instance)."""
field_names = []
template_name_suffix = "_confirm_delete"
[docs] def get(self, request, *args, **kwargs):
"""Load the object and render the template. No form is used."""
self.object = self.get_object()
context = self.get_context_data()
return self.render_to_response(context)
[docs] def post(self, request, *args, **kwargs):
"""Delete the object and redirect using the success URL."""
self.object = self.get_object()
self.object.delete()
success_message = self.get_success_message()
if success_message is not None:
self.messages.success(success_message)
return HttpResponseRedirect(self.get_success_url())
[docs]class DetailView(ModelView):
"""Display detail for a model record (instance)."""
template_name_suffix = "_detail"
# noinspection PyUnusedLocal
[docs] def get(self, request, *args, **kwargs):
"""Get the model record (instance) to be displayed."""
self.object = self.get_object()
context = self.get_context_data()
return self.render_to_response(context)
[docs]class ListView(ModelView):
"""List model records."""
allow_empty = True
"""Indicates whether an empty queryset is allowed. When ``False`` an empty queryset will raise an ``Http404``."""
empty_message = None
"""The message to display when there are no results."""
limit = None
"""The total records to be displayed on a page. Setting this value will invoke pagination."""
page_keyword = "page"
"""The GET key used to indicate the pagination page number. Defaults to ``page``."""
pagination_style = "previous-next"
"""The style for navigation through paginated results, ``numbers`` or ``previous-next``."""
template_name_suffix = "_list"
# noinspection PyUnusedLocal
[docs] def get(self, request, *args, **kwargs):
"""Get the queryset and optionally apply pagination.
The context includes ``no_results_message`` when ``allow_empty`` is ``True`` and no results are found.
``is_paginated`` is also included and is either ``True`` or ``False``.
If pagination is enabled, additional variables are also added to the context:
- ``current_page``: The same as ``page_object``.
- ``page_keyword``: The GET keyword used to indicate the current page number.
- ``page_obj``: The current page object from ``get_paginated_queryset()``.
- ``pagination_style``: The preferred output of pagination links.
- ``paginator``: The paginator instance from ``get_paginator()``.
"""
queryset = self.get_object_list()
# Handle no results.
if not queryset.exists():
if not self.allow_empty:
self.dispatch_not_found(self.get_empty_message())
context = self.get_context_data(no_results_message=self.get_empty_message())
return self.render_to_response(context)
# Handle pagination.
limit = self.get_limit()
if limit is not None:
page = self.get_paginated_queryset(queryset, limit)
self.object_list = page
context = self.get_context_data(
current_page=page, # superdjango
page_keyword=self.get_page_keyword(),
page_obj=page, # django
pagination_style=self.pagination_style,
is_paginated=True,
paginator=page.paginator
)
else:
self.object_list = queryset
context = self.get_context_data(
current_page=None, # superdjango
page_keyword=self.get_page_keyword(),
page_obj=None, # django
pagination_style=self.pagination_style,
is_paginated=False,
paginator=None
)
return self.render_to_response(context)
[docs] def get_empty_message(self):
"""Get the message to display when there are no results.
:rtype: str
"""
if self.empty_message is not None:
return self.empty_message
return _("There are currently no %s." % self.get_verbose_name_plural())
[docs] def get_limit(self):
"""Get the number of records to display per page.
:rtype: int | None
"""
return self.limit
[docs] def get_page_keyword(self):
"""Get the keyword used to identify the page number in GET.
:rtype: str
"""
return self.page_keyword
[docs] def get_paginated_queryset(self, queryset, limit):
"""Paginate the given queryset.
:param queryset: The query set to be paginated.
:type queryset: django.db.Queryset
:param limit: The number of objects per page.
:type limit: int
:rtype: Page
"""
# Paginator is needed for num_pages below.
paginator = self.get_paginator(queryset, limit)
# The page keyword determines what how the page number is identified in the URL.
page_keyword = self.get_page_keyword()
# Get the current page number.
page_query_param = self.request.GET.get(page_keyword)
page_number = page_query_param or 1
try:
page_number = int(page_number)
except ValueError:
if page_number == 'last':
page_number = paginator.num_pages
else:
message = _("The page number could not be determined.")
self.dispatch_not_found(message)
# Return the page instance or die trying.
try:
return paginator.page(page_number)
except InvalidPage as e:
message = _("Invalid page (%s): %s" % (page_number, e))
self.dispatch_not_found(message)
# noinspection PyMethodMayBeStatic
[docs] def get_paginator(self, queryset, per_page):
"""Get a paginator instance.
:rtype: Paginator
"""
return Paginator(queryset, per_page)
[docs]class UpdateView(ModelView):
"""Update and existing model record (instance).
.. note::
Because :py:class:`ModelView`` extends :py:class:`FormMixin`, this class does *not* need to implement
``get_form()`` or ``form_invalid()``. ``get()`` and ``post()`` must be implemented so that the current model
instance is bound to the form.
See also ``ModelView.get_success_url()``.
"""
template_name_suffix = "_form"
[docs] def get(self, request, *args, **kwargs):
"""Get the object and add ``form`` to the context."""
self.object = self.get_object()
form = self.get_form(instance=self.object)
context = self.get_context_data(form=form)
return self.render_to_response(context)
[docs] def post(self, request, *args, **kwargs):
"""Get the object and form and check whether the form is valid."""
self.object = self.get_object()
form = self.get_form(data=request.POST, files=request.FILES, instance=self.object)
if form.is_valid():
return self.form_valid(form)
return self.form_invalid(form)