Commit 1f55bc74 by Tom Christie

Merge pull request #2926 from tomchristie/admin-style

Admin style renderer
parents 1f50f08f 79b825ef
...@@ -153,23 +153,13 @@ You can use `StaticHTMLRenderer` either to return regular HTML pages using REST ...@@ -153,23 +153,13 @@ You can use `StaticHTMLRenderer` either to return regular HTML pages using REST
See also: `TemplateHTMLRenderer` See also: `TemplateHTMLRenderer`
## HTMLFormRenderer ## BrowsableAPIRenderer
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'` Renders data into HTML for the Browsable API:
## BrowsableAPIRenderer ![The BrowsableAPIRenderer](../img/quickstart.png)
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. 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` **.media_type**: `text/html`
...@@ -187,6 +177,38 @@ By default the response content will be rendered with the highest priority rende ...@@ -187,6 +177,38 @@ By default the response content will be rendered with the highest priority rende
def get_default_renderer(self, view): def get_default_renderer(self, view):
return JSONRenderer() 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 ## 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]. 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].
......
...@@ -68,7 +68,7 @@ Right, we'd better write some views then. Open `tutorial/quickstart/views.py` a ...@@ -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. 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 serializer_class = UserSerializer
......
...@@ -176,7 +176,7 @@ body{ ...@@ -176,7 +176,7 @@ body{
} }
#main-content h3, #main-content h4, #main-content h5 { #main-content h3, #main-content h4, #main-content h5 {
font-weight: 500; font-weight: 300;
margin-top: 15px margin-top: 15px
} }
......
...@@ -158,6 +158,9 @@ class BasePagination(object): ...@@ -158,6 +158,9 @@ class BasePagination(object):
def to_html(self): # pragma: no cover def to_html(self): # pragma: no cover
raise NotImplementedError('to_html() must be implemented to display page controls.') raise NotImplementedError('to_html() must be implemented to display page controls.')
def get_results(self, data):
return data['results']
class PageNumberPagination(BasePagination): class PageNumberPagination(BasePagination):
""" """
...@@ -261,7 +264,7 @@ class PageNumberPagination(BasePagination): ...@@ -261,7 +264,7 @@ class PageNumberPagination(BasePagination):
) )
raise NotFound(msg) 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. # The browsable API should display pagination controls.
self.display_page_controls = True self.display_page_controls = True
......
...@@ -20,6 +20,20 @@ from rest_framework.reverse import reverse ...@@ -20,6 +20,20 @@ from rest_framework.reverse import reverse
from rest_framework.utils import html 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): class PKOnlyObject(object):
""" """
This is a mock object, used for when we only need the pk of the object This is a mock object, used for when we only need the pk of the object
...@@ -235,6 +249,9 @@ class HyperlinkedRelatedField(RelatedField): ...@@ -235,6 +249,9 @@ class HyperlinkedRelatedField(RelatedField):
kwargs = {self.lookup_url_kwarg: lookup_value} kwargs = {self.lookup_url_kwarg: lookup_value}
return self.reverse(view_name, kwargs=kwargs, request=request, format=format) 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): def to_internal_value(self, data):
request = self.context.get('request', None) request = self.context.get('request', None)
try: try:
...@@ -293,7 +310,7 @@ class HyperlinkedRelatedField(RelatedField): ...@@ -293,7 +310,7 @@ class HyperlinkedRelatedField(RelatedField):
# Return the hyperlink, or error if incorrectly configured. # Return the hyperlink, or error if incorrectly configured.
try: try:
return self.get_url(value, self.view_name, request, format) url = self.get_url(value, self.view_name, request, format)
except NoReverseMatch: except NoReverseMatch:
msg = ( msg = (
'Could not resolve URL for hyperlinked relationship using ' 'Could not resolve URL for hyperlinked relationship using '
...@@ -310,6 +327,12 @@ class HyperlinkedRelatedField(RelatedField): ...@@ -310,6 +327,12 @@ class HyperlinkedRelatedField(RelatedField):
) )
raise ImproperlyConfigured(msg % self.view_name) raise ImproperlyConfigured(msg % self.view_name)
if url is None:
return None
name = self.get_name(value)
return Hyperlink(url, name)
class HyperlinkedIdentityField(HyperlinkedRelatedField): class HyperlinkedIdentityField(HyperlinkedRelatedField):
""" """
......
...@@ -593,7 +593,7 @@ class BrowsableAPIRenderer(BaseRenderer): ...@@ -593,7 +593,7 @@ class BrowsableAPIRenderer(BaseRenderer):
return view.get_view_description(html=True) return view.get_view_description(html=True)
def get_breadcrumbs(self, request): 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): def get_context(self, data, accepted_media_type, renderer_context):
""" """
...@@ -675,6 +675,90 @@ class BrowsableAPIRenderer(BaseRenderer): ...@@ -675,6 +675,90 @@ class BrowsableAPIRenderer(BaseRenderer):
return ret 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): class MultiPartRenderer(BaseRenderer):
media_type = 'multipart/form-data; boundary=BoUnDaRyStRiNg' media_type = 'multipart/form-data; boundary=BoUnDaRyStRiNg'
format = 'multipart' format = 'multipart'
......
...@@ -8,6 +8,30 @@ from django.core.urlresolvers import NoReverseMatch ...@@ -8,6 +8,30 @@ from django.core.urlresolvers import NoReverseMatch
from django.utils import six from django.utils import six
from django.utils.functional import lazy 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): 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 ...@@ -18,13 +42,15 @@ def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra
scheme = getattr(request, 'versioning_scheme', None) scheme = getattr(request, 'versioning_scheme', None)
if scheme is not None: if scheme is not None:
try: try:
return scheme.reverse(viewname, args, kwargs, request, format, **extra) url = scheme.reverse(viewname, args, kwargs, request, format, **extra)
except NoReverseMatch: except NoReverseMatch:
# In case the versioning scheme reversal fails, fallback to the # In case the versioning scheme reversal fails, fallback to the
# default implementation # 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): def _reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
content running up underneath it. */ content running up underneath it. */
h1 { h1 {
font-weight: 500; font-weight: 300;
} }
h2, h3 { h2, h3 {
...@@ -33,6 +33,14 @@ h2, h3 { ...@@ -33,6 +33,14 @@ h2, h3 {
margin-right: 1em; margin-right: 1em;
} }
td.nested {
padding: 0 !important;
}
td.nested > table {
margin: 0;
}
form select, form input, form textarea { form select, form input, form textarea {
width: 90%; width: 90%;
} }
......
...@@ -59,3 +59,7 @@ if (selectedTab && selectedTab.length > 0) { ...@@ -59,3 +59,7 @@ if (selectedTab && selectedTab.length > 0) {
// If no tab selected, display rightmost tab. // If no tab selected, display rightmost tab.
$('.form-switcher a:first').tab('show'); $('.form-switcher a:first').tab('show');
} }
$(window).load(function(){
$('#errorModal').modal('show');
});
{% 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>
{% 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>
{% 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>
{% load rest_framework %}
{% for item in value %}{% if not forloop.first%},{% endif %} {{item|format_value}}{% endfor %}
...@@ -4,6 +4,7 @@ import re ...@@ -4,6 +4,7 @@ import re
from django import template from django import template
from django.core.urlresolvers import NoReverseMatch, reverse from django.core.urlresolvers import NoReverseMatch, reverse
from django.template import Context, loader
from django.utils import six from django.utils import six
from django.utils.encoding import force_text, iri_to_uri from django.utils.encoding import force_text, iri_to_uri
from django.utils.html import escape, smart_urlquote from django.utils.html import escape, smart_urlquote
...@@ -106,6 +107,45 @@ def add_class(value, css_class): ...@@ -106,6 +107,45 @@ def add_class(value, css_class):
return value 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 # Bunch of stuff cloned from urlize
TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)', '"', "']", "'}", "'"] TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)', '"', "']", "'}", "'"]
WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('&lt;', '&gt;'), WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('&lt;', '&gt;'),
......
...@@ -3,12 +3,12 @@ from __future__ import unicode_literals ...@@ -3,12 +3,12 @@ from __future__ import unicode_literals
from django.core.urlresolvers import get_script_prefix, resolve 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 Given a url returns a list of breadcrumbs, which are each a
tuple of (name, url). tuple of (name, url).
""" """
from rest_framework.reverse import preserve_builtin_query_params
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.views import APIView from rest_framework.views import APIView
...@@ -34,7 +34,8 @@ def get_breadcrumbs(url): ...@@ -34,7 +34,8 @@ def get_breadcrumbs(url):
if not seen or seen[-1] != view: if not seen or seen[-1] != view:
suffix = getattr(view, 'suffix', None) suffix = getattr(view, 'suffix', None)
name = view_name_func(cls, suffix) 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) seen.append(view)
if url == '': if url == '':
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment