Merge pull request #2926 from tomchristie/admin-style

Admin style renderer
This commit is contained in:
Tom Christie 2015-07-30 15:01:37 +01:00
commit 1f55bc747b
18 changed files with 514 additions and 27 deletions

View File

@ -153,23 +153,13 @@ You can use `StaticHTMLRenderer` either to return regular HTML pages using REST
See also: `TemplateHTMLRenderer`
## HTMLFormRenderer
Renders data returned by a serializer into an HTML form. The output of this renderer does not include the enclosing `<form>` tags or an submit actions, as you'll probably need those to include the desired method and URL. Also note that the `HTMLFormRenderer` does not yet support including field error messages.
**Note**: The `HTMLFormRenderer` class is intended for internal use with the browsable API. It should not be considered a fully documented or stable API. The template used by the `HTMLFormRenderer` class, and the context submitted to it **may be subject to change**. If you need to use this renderer class it is advised that you either make a local copy of the class and templates, or follow the release note on REST framework upgrades closely.
**.media_type**: `text/html`
**.format**: `'.form'`
**.charset**: `utf-8`
**.template**: `'rest_framework/form.html'`
## BrowsableAPIRenderer
Renders data into HTML for the Browsable API. This renderer will determine which other renderer would have been given highest priority, and use that to display an API style response within the HTML page.
Renders data into HTML for the Browsable API:
![The BrowsableAPIRenderer](../img/quickstart.png)
This renderer will determine which other renderer would have been given highest priority, and use that to display an API style response within the HTML page.
**.media_type**: `text/html`
@ -187,6 +177,38 @@ By default the response content will be rendered with the highest priority rende
def get_default_renderer(self, view):
return JSONRenderer()
## AdminRenderer
Renders data into HTML for an admin-like display:
![The AdminRender view](../img/admin.png)
This renderer is suitable for CRUD-style web APIs that should also present a user-friendly interface for managing the data.
Note that views that have nested or list serializers for their input won't work well with the `AdminRenderer`, as the HTML forms are unable to properly support them.
**.media_type**: `text/html`
**.format**: `'.admin'`
**.charset**: `utf-8`
**.template**: `'rest_framework/admin.html'`
## HTMLFormRenderer
Renders data returned by a serializer into an HTML form. The output of this renderer does not include the enclosing `<form>` tags or an submit actions, as you'll probably need those to include the desired method and URL. Also note that the `HTMLFormRenderer` does not yet support including field error messages.
**Note**: The `HTMLFormRenderer` class is intended for internal use with the browsable API and admin interface. It should not be considered a fully documented or stable API. The template used by the `HTMLFormRenderer` class, and the context submitted to it **may be subject to change**. If you need to use this renderer class it is advised that you either make a local copy of the class and templates, or follow the release note on REST framework upgrades closely.
**.media_type**: `text/html`
**.format**: `'.form'`
**.charset**: `utf-8`
**.template**: `'rest_framework/form.html'`
## MultiPartRenderer
This renderer is used for rendering HTML multipart form data. **It is not suitable as a response renderer**, but is instead used for creating test requests, using REST framework's [test client and test request factory][testing].

BIN
docs/img/admin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -68,7 +68,7 @@ Right, we'd better write some views then. Open `tutorial/quickstart/views.py` a
"""
API endpoint that allows users to be viewed or edited.
"""
queryset = User.objects.all()
queryset = User.objects.all().order_by('-date_joined')
serializer_class = UserSerializer

View File

@ -176,7 +176,7 @@ body{
}
#main-content h3, #main-content h4, #main-content h5 {
font-weight: 500;
font-weight: 300;
margin-top: 15px
}

View File

@ -158,6 +158,9 @@ class BasePagination(object):
def to_html(self): # pragma: no cover
raise NotImplementedError('to_html() must be implemented to display page controls.')
def get_results(self, data):
return data['results']
class PageNumberPagination(BasePagination):
"""
@ -261,7 +264,7 @@ class PageNumberPagination(BasePagination):
)
raise NotFound(msg)
if paginator.count > 1 and self.template is not None:
if paginator.num_pages > 1 and self.template is not None:
# The browsable API should display pagination controls.
self.display_page_controls = True

View File

@ -20,6 +20,20 @@ from rest_framework.reverse import reverse
from rest_framework.utils import html
class Hyperlink(six.text_type):
"""
A string like object that additionally has an associated name.
We use this for hyperlinked URLs that may render as a named link
in some contexts, or render as a plain URL in others.
"""
def __new__(self, url, name):
ret = six.text_type.__new__(self, url)
ret.name = name
return ret
is_hyperlink = True
class PKOnlyObject(object):
"""
This is a mock object, used for when we only need the pk of the object
@ -235,6 +249,9 @@ class HyperlinkedRelatedField(RelatedField):
kwargs = {self.lookup_url_kwarg: lookup_value}
return self.reverse(view_name, kwargs=kwargs, request=request, format=format)
def get_name(self, obj):
return six.text_type(obj)
def to_internal_value(self, data):
request = self.context.get('request', None)
try:
@ -293,7 +310,7 @@ class HyperlinkedRelatedField(RelatedField):
# Return the hyperlink, or error if incorrectly configured.
try:
return self.get_url(value, self.view_name, request, format)
url = self.get_url(value, self.view_name, request, format)
except NoReverseMatch:
msg = (
'Could not resolve URL for hyperlinked relationship using '
@ -310,6 +327,12 @@ class HyperlinkedRelatedField(RelatedField):
)
raise ImproperlyConfigured(msg % self.view_name)
if url is None:
return None
name = self.get_name(value)
return Hyperlink(url, name)
class HyperlinkedIdentityField(HyperlinkedRelatedField):
"""

View File

@ -593,7 +593,7 @@ class BrowsableAPIRenderer(BaseRenderer):
return view.get_view_description(html=True)
def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path)
return get_breadcrumbs(request.path, request)
def get_context(self, data, accepted_media_type, renderer_context):
"""
@ -675,6 +675,90 @@ class BrowsableAPIRenderer(BaseRenderer):
return ret
class AdminRenderer(BrowsableAPIRenderer):
template = 'rest_framework/admin.html'
format = 'admin'
def render(self, data, accepted_media_type=None, renderer_context=None):
self.accepted_media_type = accepted_media_type or ''
self.renderer_context = renderer_context or {}
response = renderer_context['response']
request = renderer_context['request']
view = self.renderer_context['view']
if response.status_code == status.HTTP_400_BAD_REQUEST:
# Errors still need to display the list or detail information.
# The only way we can get at that is to simulate a GET request.
self.error_form = self.get_rendered_html_form(data, view, request.method, request)
self.error_title = {'POST': 'Create', 'PUT': 'Edit'}.get(request.method, 'Errors')
with override_method(view, request, 'GET') as request:
response = view.get(request, *view.args, **view.kwargs)
data = response.data
template = loader.get_template(self.template)
context = self.get_context(data, accepted_media_type, renderer_context)
context = RequestContext(renderer_context['request'], context)
ret = template.render(context)
# Creation and deletion should use redirects in the admin style.
if (response.status_code == status.HTTP_201_CREATED) and ('Location' in response):
response.status_code = status.HTTP_302_FOUND
response['Location'] = request.build_absolute_uri()
ret = ''
if response.status_code == status.HTTP_204_NO_CONTENT:
response.status_code = status.HTTP_302_FOUND
try:
# Attempt to get the parent breadcrumb URL.
response['Location'] = self.get_breadcrumbs(request)[-2][1]
except KeyError:
# Otherwise reload current URL to get a 'Not Found' page.
response['Location'] = request.full_path
ret = ''
return ret
def get_context(self, data, accepted_media_type, renderer_context):
"""
Render the HTML for the browsable API representation.
"""
context = super(AdminRenderer, self).get_context(
data, accepted_media_type, renderer_context
)
paginator = getattr(context['view'], 'paginator', None)
if (paginator is not None and data is not None):
try:
results = paginator.get_results(data)
except KeyError:
results = data
else:
results = data
if results is None:
header = {}
style = 'detail'
elif isinstance(results, list):
header = results[0] if results else {}
style = 'list'
else:
header = results
style = 'detail'
columns = [key for key in header.keys() if key != 'url']
details = [key for key in header.keys() if key != 'url']
context['style'] = style
context['columns'] = columns
context['details'] = details
context['results'] = results
context['error_form'] = getattr(self, 'error_form', None)
context['error_title'] = getattr(self, 'error_title', None)
return context
class MultiPartRenderer(BaseRenderer):
media_type = 'multipart/form-data; boundary=BoUnDaRyStRiNg'
format = 'multipart'

View File

@ -8,6 +8,30 @@ from django.core.urlresolvers import NoReverseMatch
from django.utils import six
from django.utils.functional import lazy
from rest_framework.settings import api_settings
from rest_framework.utils.urls import replace_query_param
def preserve_builtin_query_params(url, request=None):
"""
Given an incoming request, and an outgoing URL representation,
append the value of any built-in query parameters.
"""
if request is None:
return url
overrides = [
api_settings.URL_FORMAT_OVERRIDE,
api_settings.URL_ACCEPT_OVERRIDE
]
for param in overrides:
if param and (param in request.GET):
value = request.GET[param]
url = replace_query_param(url, param, value)
return url
def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
"""
@ -18,13 +42,15 @@ def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra
scheme = getattr(request, 'versioning_scheme', None)
if scheme is not None:
try:
return scheme.reverse(viewname, args, kwargs, request, format, **extra)
url = scheme.reverse(viewname, args, kwargs, request, format, **extra)
except NoReverseMatch:
# In case the versioning scheme reversal fails, fallback to the
# default implementation
pass
url = _reverse(viewname, args, kwargs, request, format, **extra)
else:
url = _reverse(viewname, args, kwargs, request, format, **extra)
return _reverse(viewname, args, kwargs, request, format, **extra)
return preserve_builtin_query_params(url, request)
def _reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):

View File

@ -3,7 +3,7 @@
content running up underneath it. */
h1 {
font-weight: 500;
font-weight: 300;
}
h2, h3 {
@ -33,6 +33,14 @@ h2, h3 {
margin-right: 1em;
}
td.nested {
padding: 0 !important;
}
td.nested > table {
margin: 0;
}
form select, form input, form textarea {
width: 90%;
}

View File

@ -59,3 +59,7 @@ if (selectedTab && selectedTab.length > 0) {
// If no tab selected, display rightmost tab.
$('.form-switcher a:first').tab('show');
}
$(window).load(function(){
$('#errorModal').modal('show');
});

View File

@ -0,0 +1,232 @@
{% load url from future %}
{% load staticfiles %}
{% load rest_framework %}
<!DOCTYPE html>
<html>
<head>
{% block head %}
{% block meta %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="robots" content="NONE,NOARCHIVE" />
{% endblock %}
<title>{% block title %}Django REST framework{% endblock %}</title>
{% block style %}
{% block bootstrap_theme %}
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/bootstrap.min.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/bootstrap-tweaks.css" %}"/>
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/prettify.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/default.css" %}"/>
{% endblock %}
{% endblock %}
</head>
{% block body %}
<body class="{% block bodyclass %}{% endblock %}">
<div class="wrapper">
{% block navbar %}
<div class="navbar navbar-static-top {% block bootstrap_navbar_variant %}navbar-inverse{% endblock %}">
<div class="container">
<span>
{% block branding %}
<a class='navbar-brand' rel="nofollow" href='http://www.django-rest-framework.org'>
Django REST framework <span class="version">{{ version }}</span>
</a>
{% endblock %}
</span>
<ul class="nav navbar-nav pull-right">
{% block userlinks %}
{% if user.is_authenticated %}
{% optional_logout request user %}
{% else %}
{% optional_login request %}
{% endif %}
{% endblock %}
</ul>
</div>
</div>
{% endblock %}
<div class="container">
{% block breadcrumbs %}
<ul class="breadcrumb">
{% for breadcrumb_name, breadcrumb_url in breadcrumblist %}
{% if forloop.last %}
<li class="active"><a href="{{ breadcrumb_url }}">{{ breadcrumb_name }}</a></li>
{% else %}
<li><a href="{{ breadcrumb_url }}">{{ breadcrumb_name }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endblock %}
<!-- Content -->
<div id="content">
{% if 'GET' in allowed_methods %}
<form id="get-form" class="pull-right">
<fieldset>
<div class="btn-group format-selection">
<button class="btn btn-primary dropdown-toggle" data-toggle="dropdown">
Format <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for format in available_formats %}
<li>
<a class="format-option"
href='{% add_query_param request api_settings.URL_FORMAT_OVERRIDE format %}'
rel="nofollow">
{{ format }}
</a>
</li>
{% endfor %}
</ul>
</div>
</fieldset>
</form>
{% endif %}
{% if post_form %}
<button type="button" class="button-form btn btn-primary" data-toggle="modal" data-target="#createModal">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create
</button>
{% endif %}
{% if put_form %}
<button type="button" class="button-form btn btn-primary" data-toggle="modal" data-target="#editModal">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if delete_form %}
<form class="button-form" action="{{ request.get_full_path }}" method="POST">
{% csrf_token %}
<input type="hidden" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="DELETE" />
<button class="btn btn-danger">
<span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Delete
</button>
</form>
{% endif %}
<div class="content-main">
<div class="page-header">
<h1>{{ name }}</h1>
</div>
<div style="float:left">
{% block description %}
{{ description }}
{% endblock %}
</div>
{% if paginator %}
<nav style="float: right">
{% get_pagination_html paginator %}
</nav>
{% endif %}
<div class="request-info" style="clear: both" >
{% if style == 'list' %}
{% include "rest_framework/admin/list.html" %}
{% else %}
{% include "rest_framework/admin/detail.html" %}
{% endif %}
</div>
{% if paginator %}
<nav style="float: right">
{% get_pagination_html paginator %}
</nav>
{% endif %}
</div>
</div>
<!-- END Content -->
</div><!-- /.container -->
</div><!-- ./wrapper -->
<!-- Create Modal -->
<div class="modal fade" id="createModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="myModalLabel">Create</h4>
</div>
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal" novalidate>
<div class="modal-body">
<fieldset>
{{ post_form }}
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="myModalLabel">Edit</h4>
</div>
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal" novalidate>
<div class="modal-body">
<fieldset>
{{ put_form }}
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PUT" type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% if error_form %}
<!-- Errors Modal -->
<div class="modal" id="errorModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="myModalLabel">{{ error_title }}</h4>
</div>
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal" novalidate>
<div class="modal-body">
<fieldset>
{{ error_form }}
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="{{ request.method }}" type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% block script %}
<script src="{% static "rest_framework/js/jquery-1.8.1-min.js" %}"></script>
<script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script>
<script src="{% static "rest_framework/js/prettify-min.js" %}"></script>
<script src="{% static "rest_framework/js/default.js" %}"></script>
{% endblock %}
</body>
{% endblock %}
</html>

View File

@ -0,0 +1,10 @@
{% load rest_framework %}
<table class="table table-striped">
<tbody>
{% for key, value in results.items %}
{% if key in details %}
<tr><th>{{ key|capfirst }}</th><td {{ value|add_nested_class }}>{{ value|format_value }}</td></tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,21 @@
{% load rest_framework %}
<table class="table table-striped">
<thead>
<tr>{% for column in columns%}<th>{{ column|capfirst }}</th>{% endfor %}<th></th></tr>
</thead>
<tbody>
{% for row in results %}
<tr>
{% for key, value in row.items %}
{% if key in columns %}
<td {{ value|add_nested_class }} >
{{ value|format_value }}
</td>
{% endif %}
{% endfor %}
<td><a href="{{ row.url }}"><span class="glyphicon glyphicon-chevron-right" aria-hidden="true"></span>
</a></td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,11 @@
{% load rest_framework %}
<table class="table table-striped">
<tbody>
{% for item in value %}
<tr>
<th>{{ forloop.counter0 }}</th>
<td>{{ item|format_value }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,2 @@
{% load rest_framework %}
{% for item in value %}{% if not forloop.first%},{% endif %} {{item|format_value}}{% endfor %}

View File

@ -4,6 +4,7 @@ import re
from django import template
from django.core.urlresolvers import NoReverseMatch, reverse
from django.template import Context, loader
from django.utils import six
from django.utils.encoding import force_text, iri_to_uri
from django.utils.html import escape, smart_urlquote
@ -106,6 +107,45 @@ def add_class(value, css_class):
return value
@register.filter
def format_value(value):
if getattr(value, 'is_hyperlink', False):
return mark_safe('<a href=%s>%s</a>' % (value, escape(value.name)))
if value in (True, False, None):
return mark_safe('<code>%s</code>' % {True: 'true', False: 'false', None: 'null'}[value])
elif isinstance(value, list):
if any([isinstance(item, (list, dict)) for item in value]):
template = loader.get_template('rest_framework/admin/list_value.html')
else:
template = loader.get_template('rest_framework/admin/simple_list_value.html')
context = Context({'value': value})
return template.render(context)
elif isinstance(value, dict):
template = loader.get_template('rest_framework/admin/dict_value.html')
context = Context({'value': value})
return template.render(context)
elif isinstance(value, six.string_types):
if (
(value.startswith('http:') or value.startswith('https:')) and not
re.search(r'\s', value)
):
return mark_safe('<a href="{value}">{value}</a>'.format(value=escape(value)))
elif '@' in value and not re.search(r'\s', value):
return mark_safe('<a href="mailto:{value}">{value}</a>'.format(value=escape(value)))
elif '\n' in value:
return mark_safe('<pre>%s</pre>' % escape(value))
return six.text_type(value)
@register.filter
def add_nested_class(value):
if isinstance(value, dict):
return 'class=nested'
if isinstance(value, list) and any([isinstance(item, (list, dict)) for item in value]):
return 'class=nested'
return ''
# Bunch of stuff cloned from urlize
TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)', '"', "']", "'}", "'"]
WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('&lt;', '&gt;'),

View File

@ -3,12 +3,12 @@ from __future__ import unicode_literals
from django.core.urlresolvers import get_script_prefix, resolve
def get_breadcrumbs(url):
def get_breadcrumbs(url, request=None):
"""
Given a url returns a list of breadcrumbs, which are each a
tuple of (name, url).
"""
from rest_framework.reverse import preserve_builtin_query_params
from rest_framework.settings import api_settings
from rest_framework.views import APIView
@ -34,7 +34,8 @@ def get_breadcrumbs(url):
if not seen or seen[-1] != view:
suffix = getattr(view, 'suffix', None)
name = view_name_func(cls, suffix)
breadcrumbs_list.insert(0, (name, prefix + url))
insert_url = preserve_builtin_query_params(prefix + url, request)
breadcrumbs_list.insert(0, (name, insert_url))
seen.append(view)
if url == '':