robinki
robinki

Reputation: 372

Customize DjangoRestFramework Browsable API

I'm using Django Rest Framework 3.11.0 and I want to use the BrowsableAPIRenderer with a customized template for rendering the details of an instance. I only want to override the rendering of the dict/json, marked red in the image below, and I want to keep all the rest.

By overwriting restframework/api.html I only managed to change the title, heading and some fields, but I did not find a way to render the details of an instance e.g. in a table. Is there a way to do this?

Clarification: I have models with large dictionaries that I want to display prettier than just as inline strings. I thin that when I find out how to customize the (already beautiful) Django RestFramework BrowsableAPI, I will also be able to solve my problem.

(Look at my Update 2 in case you want to solve a similar problem.)

Screenshot


Update 1

This is where I got with Bedilbeks answer (up to the first update).

I don't want to change all views, so I'm not registering the renderer globally.

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
        'rest_framework.renderers.BrowsableAPIRenderer',
         # 'users.renderers.CustomBrowsableAPIRenderer',
    ]
}

Instead I am setting the renderer_classes for my UserViewSet and use my CustomBrowsableAPIRenderer here.

class UserViewSet(GenericViewSet, ListModelMixin, RetrieveModelMixin):
    queryset = UserModel.objects.all()
    serializer_class = UserSerializer
    renderer_classes = [renderers.JSONRenderer, CustomBrowsableAPIRenderer]

I need to override the api.html template, but I do not want this change to apply everywhere, so I am dynamically choosing the the template in the renderer. By default the BrowsableAPIRenderer has a template = "rest_framework/api.html" property, but I need logic so I'm using the @property decorator to do the following:

If we are in detail view and a "table" parameter is present, return my template, else return the default.

class CustomBrowsableAPIRenderer(BrowsableAPIRenderer):
    @property
    def template(self):
        view = self.renderer_context.get("view", {})
        table = "table" in view.request.query_params
        if view and hasattr(view, "detail") and view.detail and table:
            return "users/api.html"  # custom template
        else:
            return "rest_framework/api.html"  # default

    def get_default_renderer(self, view):
        table = "table" in view.request.query_params
        if hasattr(view, "detail") and view.detail and table:
            return TableHtmlRenderer()

        return super().get_default_renderer(view)

The crucial section of api.html looks like this (around line 123).

...
{% block style %}
    {{ block.super }}
    <link rel="stylesheet" type="text/css" href="{% static "css/api.css" %}"/>
{% endblock %}

<!-- HERE IS THE ACTUAL CONTENT -->
</span></pre><div class="prettyprint" style="overflow: auto;">{{ content|urlize_quoted_links }}</div>
            </div>
...

I'm actually not doing this for the User model and ViewSet, but I'm sticking to it for the sake of the example. In my model, I have larger JSON elements that I want to render, so I am doing some preprocessing in my TableHTMLRenderer to return JSON in indented form.

class TableHtmlRenderer(TemplateHTMLRenderer):
    media_type = "text/html"
    format = "api"
    template_name = "table_template.html"

    def get_template_context(self, data, renderer_context):
        for key in data.keys():
            try:
                data[key] = json.dumps(json.loads(data[key]), indent=4)
            except (JSONDecodeError, TypeError):
                pass

        context = {
            "data": data
        }

        response = renderer_context["response"]
        if response.exception:
            context["status_code"] = response.status_code

        return context

So controlled by the URL, I can switch between the default renderer and the Custom/Table renderer.

http://localhost.me:8000/api/users/1/?table

http://localhost.me:8000/api/users/1/

So far so good, I now have my own Renderer classes and I can modify how the API view for my User instance looks. I'm still struggling with the table, because line-breaks on long lines don't work and it won't stay inside of the div's boundaries.

Here is the app.css which is loaded in the api.html template.

pre.inline {
    padding: 0;
    border: none;
    word-break: break-all;
    word-wrap: break-word;
    display: contents;
}

table, th, td {
    vertical-align: top;
    padding: 2px;
    text-align: left;}

table {
    //table-layout: fixed;
    width: 100% !important;
    word-wrap:break-word;
}

th, td {
    border-bottom: 1px solid #ddd;
    overflow: auto;
    width: 100%;
}

tr:hover {
    background-color: #f2f2f2;
}

tr:nth-child(even) {
    background-color: #f5f5f5;
}

Update 2

Since I can now display some views with a customized BrowsableAPIRenderer and templates with quite a few hacks, I came back to the problem that lead me to this question. I wanted to get to know how DRF renders my models, to make changes in order to display large nested dictionaries.

I found out that the BrowsableAPIRenderer inserts the model contents as a single string int he api.html template like this {{ content|urlize_quoted_links }} inside of <pre> tags. The insertion takes place in the BrowsableAPIRenderer.get_content method.

# original code
renderer_context['indent'] = 4
content = renderer.render(data, accepted_media_type, renderer_context)

I now see that all I had to do is subclass BrowsableAPIRenderer and override the get_content method. I'm doing it like this.

class LogBrowsableAPIRenderer(BrowsableAPIRenderer):    
    def get_content(self, renderer, data, accepted_media_type, renderer_context):
        """
        Extends BrowsableAPIRenderer.get_content.
        """
        if not renderer:
            return '[No renderers were found]'

        renderer_context['indent'] = 4
        # content = renderer.render(data, accepted_media_type, renderer_context)

        # try to convert all string-values into dictionaries
        data_dict = dict(data.items())
        for k in data_dict.keys():
            try:
                data_dict[k] = json.loads(data_dict[k], strict=False)
            except JSONDecodeError:
                # ignore errors and move on for now
                pass

        # dump into indented string again
        content = json.dumps(data_dict, indent=4, sort_keys=True).encode(encoding="utf-8")

        render_style = getattr(renderer, 'render_style', 'text')
        assert render_style in ['text', 'binary'], 'Expected .render_style "text" or "binary", but got "%s"' % render_style
        if render_style == 'binary':
            return '[%d bytes of binary content]' % len(content)

        return content

I also realize that I could have worded my question differently to maybe come to this closure more quickly.

Upvotes: 6

Views: 4335

Answers (1)

Bedilbek
Bedilbek

Reputation: 899

This is not the most appropriate and best answer, but I think it is more or less what you want and it is a little hack.

Let's say we want to expose /users/ endpoint and we have the following views.py:

from django.contrib.auth import get_user_model
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.routers import DefaultRouter
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet

UserModel = get_user_model()


class UserSerializer(ModelSerializer):
    class Meta:
        model = UserModel
        fields = ('first_name', 'last_name')


class UserViewSet(GenericViewSet, ListModelMixin, RetrieveModelMixin):
    queryset = UserModel.objects.all()
    serializer_class = UserSerializer


router = DefaultRouter()
router.register('users', UserViewSet)
urlpatterns = router.urls


So, first, let's create renderers.py inside one of our apps (let's say we have users app):

from rest_framework.renderers import BrowsableAPIRenderer, TemplateHTMLRenderer


class TableHtmlRenderer(TemplateHTMLRenderer):
    media_type = 'text/html'
    format = 'api'
    template_name = 'users/table_template.html'

    def get_template_context(self, data, renderer_context):
        context = {'data': data}
        response = renderer_context['response']
        if response.exception:
            context['status_code'] = response.status_code
        return context


class CustomBrowsableAPIRenderer(BrowsableAPIRenderer):

    def get_default_renderer(self, view):
        if view.detail:
            return TableHtmlRenderer()

        return super().get_default_renderer(view)

In this way we are overriding BrowsableAPIRenderer with our CustomBrowsableAPIRenderer just to change the default renderer for our content (In our case it is dict obj that is json serializable). We check whether our view is detail=True by accessing viewset.detail attribute.

Then, we need to override existing TemplateHTMLRenderer with our own TableHTMLRenderer to give our dict object as a data to a context of users/table_template.html

So, now we have to create our users/table_template.html:

<table>
    <tr>
        <th>Key</th>
        <th>Value</th>
    </tr>
    {% for key, value in data.items %}
    <tr>
        <td>{{ key }}</td>
        <td>{{ value }}</td>
    </tr>
    {% endfor %}
</table>

Now, we are ready to check just after enabling our renderers in the settings.py:

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
        'users.renderers.CustomBrowsableAPIRenderer',
    ]
}

If you see, now we replaced rest_framework.renderers.BrowsableAPIRenderer with our own users.renderers.CustomBrowsableAPIRenderer custom renderer.

enter image description here

We see that now we have table instead of json structure.

I hope, this should work on your side too.


UPDATE

If we want to get rid of whitespace or we want more of a custom modification, we have to override and change the api.html. So, we create our own users/api.html:

{% extends "rest_framework/base.html" %}
{% load i18n %}
{% load rest_framework %}

{% block content %}
...
</span></pre>
            <div class="prettyprint">{{ content|urlize_quoted_links }}
            </div>
        </div>
...
{% endblock content %}

where ... 3 dots are put, we have to copy and paste the rest of the base.html starting from content block because content block is too big and we want to change something that inside this block. We can not do it without copy-pasting the whole content block. If we compare our template with old base.html, the place where </span> element finishes, we remove {{ content|urlize_quoted_links }} context and put it inside a new div after the end of </pre> element as we see in the code example, so that now we do not use <pre> element and no extra whitespace is put.

Then, if we look at the result: enter image description here

But, what is bad, now we have changed and overrode the whole template, so we broke other views too: enter image description here

As a result, you see that our result became clumsy and we have made too much of hack to come to result that we want. That's why I would not really recommend overriding api.html just to change that content part. Instead, I would use my first answer which is less hacky, by overriding renderer classes.

Upvotes: 1

Related Questions