Skip to content

Commit

Permalink
Merge pull request #1832 from uktrade/feature/api-docs
Browse files Browse the repository at this point in the history
Enable the Django Rest Framework built-in documentation feature
  • Loading branch information
reupen authored Jul 9, 2019
2 parents 78904d3 + cc88f43 commit b9bc3a0
Show file tree
Hide file tree
Showing 14 changed files with 138 additions and 32 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ Leeloo can run on any Heroku-style platform. Configuration is performed via the
| `DJANGO_SENTRY_DSN` | Yes | |
| `DJANGO_SETTINGS_MODULE` | Yes | |
| `DEFAULT_BUCKET` | Yes | S3 bucket for object storage. |
| `ENABLE_API_DOCUMENTATION` | No | Whether API documentation is made available at the URL path `/docs` (default=False). |
| `ENABLE_DAILY_ES_SYNC` | No | Whether to enable the daily ES sync (default=False). |
| `ENABLE_SPI_REPORT_GENERATION` | No | Whether to enable daily SPI report (default=False). |
| `ES_INDEX_PREFIX` | Yes | Prefix to use for indices and aliases |
Expand Down
6 changes: 6 additions & 0 deletions changelog/api-docs.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
The Django Rest Framework built-in documentation was enabled at the URL path ``/docs``.

This is currently only enabled if the ``ENABLE_API_DOCUMENTATION`` environment variable is
set to ``True`` as the documentation is not fully functional as yet.

You must also log into Django admin prior to accessing ``/docs``.
5 changes: 5 additions & 0 deletions config/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}

# Currently the docs (at /docs) are behind at setting as they are not fully accurate yet
# TODO: Remove this once we are happy with the docs
ENABLE_API_DOCUMENTATION = env.bool('ENABLE_API_DOCUMENTATION', default=False)
API_DOCUMENTATION_TITLE = 'Data Hub API'

# Simplified static file serving.
# https://warehouse.python.org/project/whitenoise/

Expand Down
2 changes: 2 additions & 0 deletions config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@
ACTIVITY_STREAM_OUTGOING_URL = 'http://activity.stream/'
ACTIVITY_STREAM_OUTGOING_ACCESS_KEY_ID = 'some-outgoing-id'
ACTIVITY_STREAM_OUTGOING_SECRET_ACCESS_KEY = 'some-outgoing-secret'

ENABLE_API_DOCUMENTATION = True
27 changes: 21 additions & 6 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.contrib import admin
from django.urls import include, path
from oauth2_provider.views import TokenView
from rest_framework.authentication import SessionAuthentication
from rest_framework.documentation import include_docs_urls

from config import api_urls
from datahub.ping.views import ping
Expand All @@ -25,15 +27,28 @@
),
]

if settings.ENABLE_API_DOCUMENTATION:
unversioned_urls += [
path(
'docs',
include_docs_urls(
title=settings.API_DOCUMENTATION_TITLE,
authentication_classes=[SessionAuthentication],
),
),
]


if settings.DEBUG:
import debug_toolbar
unversioned_urls += [
path('__debug__/', include(debug_toolbar.urls)),
]


urlpatterns = [
# V1 has actually no version in the URL
path('', include((api_urls.v1_urls, 'api'), namespace='api-v1')),
path('v3/', include((api_urls.v3_urls, 'api'), namespace='api-v3')),
path('v4/', include((api_urls.v4_urls, 'api'), namespace='api-v4')),
] + unversioned_urls

if settings.DEBUG:
import debug_toolbar
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
35 changes: 35 additions & 0 deletions datahub/core/test/test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import pytest
from django.conf import settings
from django.urls import reverse
from rest_framework import status

from datahub.core.test_utils import get_admin_user


@pytest.mark.django_db
class TestDocsView:
"""Test the DRF docs view."""

def test_returns_200_if_logged_in(self, client):
"""
Test that a 200 is returned if a user is logged in using session authentication.
This is primarily to make sure that the page is functioning and no views are breaking it.
"""
password = 'test-password'
user = get_admin_user(password=password)
client.login(username=user.email, password=password)

url = reverse('api-docs:docs-index')
response = client.get(url)

assert response.status_code == status.HTTP_200_OK
assert settings.API_DOCUMENTATION_TITLE.encode('utf-8') in response.rendered_content

def test_returns_403_if_not_logged_in(self, client):
"""Test that a 403 error is returned if the user is not logged in."""
url = reverse('api-docs:docs-index')
response = client.get(url)

assert response.status_code == status.HTTP_403_FORBIDDEN
assert settings.API_DOCUMENTATION_TITLE.encode('utf-8') not in response.rendered_content
35 changes: 22 additions & 13 deletions datahub/dnb_match/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
from rest_framework.generics import GenericAPIView
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework.views import APIView

from datahub.company.models import Company
from datahub.dnb_match.serializers import (
Expand All @@ -15,25 +16,29 @@
from datahub.oauth.scopes import Scope


class BaseMatchingInformationAPIView(GenericAPIView):
class BaseMatchingInformationAPIView(APIView):
"""Base matching information APIView."""

required_scopes = (Scope.internal_front_end,)

queryset = Company.objects.select_related('dnbmatchingresult')

lookup_url_kwarg = 'company_pk'
def _get_company(self):
obj = get_object_or_404(self.queryset, pk=self.kwargs['company_pk'])
self.check_object_permissions(self.request, obj)

def _get_matching_information(self):
return obj

@classmethod
def _get_matching_information(cls, company):
"""Get matching candidates and current selection."""
company = self.get_object()
try:
matching_result = company.dnbmatchingresult.data
except ObjectDoesNotExist:
matching_result = {}

response = {
'result': self._get_dnb_match_result(matching_result),
'result': cls._get_dnb_match_result(matching_result),
'candidates': _get_list_of_latest_match_candidates(company.pk),
'company': model_to_dict(company, fields=('id', 'name', 'trading_names')),
}
Expand All @@ -57,41 +62,45 @@ class MatchingInformationAPIView(BaseMatchingInformationAPIView):

def get(self, request, **kwargs):
"""Get matching information."""
return self._get_matching_information()
company = self._get_company()

return self._get_matching_information(company)


class SelectMatchAPIView(BaseMatchingInformationAPIView):
"""APIView for selecting matching company."""

def post(self, request, **kwargs):
"""Create match selection."""
company = self.get_object()
company = self._get_company()

serializer = SelectMatchingCandidateSerializer(
data=request.data,
context={**self.get_serializer_context(), 'company': company},
context={'request': request, 'company': company},
)
serializer.is_valid(raise_exception=True)
serializer.save()
company.refresh_from_db()

return self._get_matching_information()
return self._get_matching_information(company)


class SelectNoMatchAPIView(BaseMatchingInformationAPIView):
"""APIView for selecting no match."""

def post(self, request, **kwargs):
"""Create no match."""
company = self.get_object()
company = self._get_company()

serializer = SelectNoMatchSerializer(
data=request.data,
context={**self.get_serializer_context(), 'company': company},
context={'request': request, 'company': company},
)
serializer.is_valid(raise_exception=True)
serializer.save()
company.refresh_from_db()

return self._get_matching_information()
return self._get_matching_information(company)


def _replace_dnb_country_fields(data):
Expand Down
2 changes: 1 addition & 1 deletion datahub/investment/project/proposition/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = {
**super().get_serializer_context(),
'current_user': self.request.user,
'current_user': self.request.user if self.request else None,
}
return context

Expand Down
2 changes: 1 addition & 1 deletion datahub/investment/project/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def get_serializer_context(self):
"""Extra context provided to the serializer class."""
return {
**super().get_serializer_context(),
'current_user': self.request.user,
'current_user': self.request.user if self.request else None,
}


Expand Down
2 changes: 1 addition & 1 deletion datahub/omis/invoice/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def get_object(self):
:raises Http404: if the invoice doesn't exist
"""
invoice = self.get_order().invoice
invoice = self.get_order_or_404().invoice
if not invoice:
raise Http404('The specified invoice does not exist.')
return invoice
Expand Down
41 changes: 33 additions & 8 deletions datahub/omis/order/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def get_serializer_context(self):
"""Extra context provided to the serializer class."""
return {
**super().get_serializer_context(),
'current_user': self.request.user,
'current_user': self.request.user if self.request else None,
}


Expand Down Expand Up @@ -208,22 +208,47 @@ class BaseNestedOrderViewSet(CoreViewSet):

def get_order(self):
"""
:returns: the main order from url kwargs.
:raises Http404: if the order doesn't exist
:returns: the main order from url kwargs (or None if it a matching order is not found).
"""
try:
order = self.order_queryset.get(
return self.order_queryset.get(
**{self.order_lookup_field: self.kwargs[self.order_lookup_url_kwarg]},
)
except Order.DoesNotExist:
except (Order.DoesNotExist, KeyError):
return None

def get_order_or_404(self):
"""
:returns: the main order from url kwargs.
:raises Http404: if the order doesn't exist
"""
order = self.get_order()

if not order:
raise Http404('The specified order does not exist.')

return order

def initial(self, request, *args, **kwargs):
"""
Makes sure that the order_pk in the URL path refers to an existent order.
:raises Http404: if a matching order cannot be found
"""
super().initial(request, *args, **kwargs)

self.get_order_or_404()

def get_serializer_context(self):
"""Extra context provided to the serializer class."""
"""
Extra context provided to the serializer class.
Note: The DRF built-in docs feature will call this function with an empty dict in
self.kwargs. The function should not fail in this case.
"""
return {
**super().get_serializer_context(),
'order': self.get_order(),
'current_user': self.request.user,
'current_user': self.request.user if self.request else None,
}
2 changes: 1 addition & 1 deletion datahub/omis/quote/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def get_object(self):
:raises Http404: if the quote doesn't exist
"""
quote = self.get_order().quote
quote = self.get_order_or_404().quote
if not quote:
raise Http404('The specified quote does not exist.')
return quote
Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ django-pglocks==1.0.2
django-model-utils==3.2.0
django-mptt==0.10.0
django-oauth-toolkit==1.2.0
coreapi==2.3.3 # required for DRF schema features
oauthlib==2.1.0
whitenoise==4.1.2

Expand Down
9 changes: 8 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ celery[redis]==4.3
certifi==2019.3.9 # via requests, sentry-sdk
chardet==3.0.4
click==7.0 # via pip-tools, towncrier
coreapi==2.3.3
coreschema==0.0.4 # via coreapi
coverage==4.5.3 # via pytest-cov
cssselect==1.0.3
decorator==4.4.0 # via ipython, traitlets
Expand Down Expand Up @@ -67,8 +69,9 @@ ipaddress==1.0.22 # via mail-parser
ipdb==0.12
ipython-genutils==0.2.0 # via traitlets
ipython==7.6.1
itypes==1.1.0 # via coreapi
jedi==0.13.3 # via ipython
jinja2==2.10.1 # via towncrier
jinja2==2.10.1 # via coreschema, towncrier
jmespath==0.9.4 # via boto3, botocore
kombu==4.6.3
lxml==4.3.4
Expand Down Expand Up @@ -125,10 +128,14 @@ text-unidecode==1.2 # via faker
toml==0.10.0 # via towncrier
towncrier==19.2.0
traitlets==4.3.2 # via ipython
uritemplate==3.0.0 # via coreapi
urllib3==1.25.3 # via botocore, elasticsearch, requests, sentry-sdk
vine==1.3.0 # via amqp, celery
watchdog==0.9.0
wcwidth==0.1.7 # via prompt-toolkit, pytest
werkzeug==0.15.4
whitenoise==4.1.2
zipp==0.5.1 # via importlib-metadata

# The following packages are considered to be unsafe in a requirements file:
# setuptools==41.0.1 # via flake8-blind-except, flake8-import-order, ipdb, ipython

0 comments on commit b9bc3a0

Please sign in to comment.