Commit b9b67d10 by Peter Fogg

Generate API client credentials.

ECOM-3946
parent bb5874d5
...@@ -26,13 +26,21 @@ ...@@ -26,13 +26,21 @@
&.request-pending { &.request-pending {
border-top: 2px solid $orange; border-top: 2px solid $orange;
} }
&.request-denied {
border-top: 2px solid $red;
}
&.request-approved {
border-top: 2px solid $green;
}
} }
#api-access-status { #api-access-status {
@extend %t-copy-base; @extend %t-copy-base;
} }
#api-access-request { .api-management-form {
padding: 0 $baseline $baseline $baseline; padding: 0 $baseline $baseline $baseline;
...@@ -91,4 +99,17 @@ ...@@ -91,4 +99,17 @@
text-transform: none; text-transform: none;
} }
} }
.application-info {
margin: $baseline 0;
p {
@extend %t-copy-base;
margin: $baseline/2 0;
.application-label {
@extend %t-weight4;
}
}
}
} }
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)} ${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}
</h1> </h1>
<form action="" method="post" id="api-access-request"> <form action="" method="post" class="api-management-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}"> <input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n} ${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Request API Access')}"/> <input id="api-access-submit" type="submit" value="${_('Request API Access')}"/>
......
...@@ -11,17 +11,52 @@ from openedx.core.djangolib.markup import Text, HTML ...@@ -11,17 +11,52 @@ from openedx.core.djangolib.markup import Text, HTML
<div id="api-access-wrapper"> <div id="api-access-wrapper">
<h1 id="api-access-request-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1> <h1 id="api-access-request-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1>
<div class="request-status request-${status}"> <div class="request-status request-${status}">
% if status == ApiAccessRequest.PENDING: <p id="api-access-status">
## Translators: "platform_name" is the name of this Open edX installation. "link_start" and "link_end" are the HTML for a link to the API documentation. "api_support_email_link" is HTML for a link to email the API support staff. % if status == ApiAccessRequest.PENDING:
<p id="api-access-status">${Text(_('Your request to access the {platform_name} Course Catalog API is being processed. You will receive a message at the email address in your profile when processing is complete. You can also return to this page to see the status of your API access request. To learn more about the {platform_name} Course Catalog API, visit {link_start}our API documentation page{link_end}. For questions about using this API, visit our FAQ page or contact {api_support_email_link}.')).format( ## Translators: "platform_name" is the name of this Open edX installation.
platform_name=Text(settings.PLATFORM_NAME), ${Text(_('Your request to access the {platform_name} Course Catalog API is being processed. You will receive a message at the email address in your profile when processing is complete. You can also return to this page to see the status of your API access request.')).format(
link_start=HTML('<a href="{}">').format(Text(api_support_link)), platform_name=Text(settings.PLATFORM_NAME)
link_end=HTML('</a>'), )}
api_support_email_link=HTML('<a href="mailto:{email}">{email}</a>').format(email=Text(api_support_email))
)}</p>
% endif
## TODO (ECOM-3946): Add status text for 'active' and 'denied', as well as API client creation. % elif status == ApiAccessRequest.DENIED:
## Translators: "platform_name" is the name of this Open edX installation. "api_support_email_link" is HTML for a link to email the API support staff.
${Text(_('Your request to access the {platform_name} Course Catalog API has been denied. If you think this is an error, or for other questions about using this API, contact {api_support_email_link}.')).format(
platform_name=Text(settings.PLATFORM_NAME),
api_support_email_link=HTML('<a href="mailto:{email}">{email}</a>').format(email=Text(api_support_email))
)}
% elif status == ApiAccessRequest.APPROVED:
${Text(_('Your request to access the {platform_name} Course Catalog API has been approved.')).format(
platform_name=Text(settings.PLATFORM_NAME)
)}
% if application:
<div class="application-info">
<p><span class="application-label">${_("Application Name") + ":"}</span> ${application.name}</p>
<p><span class="application-label">${_("API Client ID") + ":"}</span> ${application.client_id}</p>
<p><span class="application-label">${_("API Client Secret") + ":"}</span> ${application.client_secret}</p>
<p><span class="application-label">${_("Redirect URLs") + ":"}</span> ${application.redirect_uris}</p>
</div>
<p>${_('If you would like to regenerate your API client information, please use the form below.')}</p>
% endif
<form id="api-form-fields" method="post" class="api-management-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Generate API client credentials')}"/>
</form>
% endif
</p>
<p>
## Translators: "platform_name" is the name of this Open edX installation. "link_start" and "link_end" are the HTML for a link to the API documentation. "api_support_email_link" is HTML for a link to email the API support staff.
${Text(_('To learn more about the {platform_name} Course Catalog API, visit {link_start}our API documentation page{link_end}. For questions about using this API, contact {api_support_email_link}.')).format(
platform_name=Text(settings.PLATFORM_NAME),
link_start=HTML('<a href="{}">').format(Text(api_support_link)),
link_end=HTML('</a>'),
api_support_email_link=HTML('<a href="mailto:{email}">{email}</a>').format(email=Text(api_support_email))
)}
</p>
</div> </div>
</div> </div>
"""Decorators for API access management.""" """Decorators for API access management."""
from functools import wraps from functools import wraps
from django.core.urlresolvers import reverse
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.shortcuts import redirect
from openedx.core.djangoapps.api_admin.models import ApiAccessConfig from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
def api_access_enabled_or_404(view): def api_access_enabled_or_404(view_func):
"""If API access management feature is not enabled, return a 404.""" """If API access management feature is not enabled, return a 404."""
@wraps(view) @wraps(view_func)
def wrapped_view(request, *args, **kwargs): def wrapped_view(view_obj, *args, **kwargs):
"""Wrapper for the view function.""" """Wrapper for the view function."""
if ApiAccessConfig.current().enabled: if ApiAccessConfig.current().enabled:
return view(request, *args, **kwargs) return view_func(view_obj, *args, **kwargs)
return HttpResponseNotFound() return HttpResponseNotFound()
return wrapped_view return wrapped_view
def require_api_access(view_func):
"""If the requesting user does not have API access, bounce them to the request form."""
@wraps(view_func)
def wrapped_view(view_obj, *args, **kwargs):
"""Wrapper for the view function."""
if ApiAccessRequest.has_api_access(args[0].user):
return view_func(view_obj, *args, **kwargs)
return redirect(reverse('api_admin:api-request'))
return wrapped_view
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('api_admin', '0004_auto_20160412_1506'),
]
operations = [
migrations.AlterField(
model_name='apiaccessrequest',
name='user',
field=models.OneToOneField(related_name='api_access_request', to=settings.AUTH_USER_MODEL),
),
]
...@@ -32,7 +32,7 @@ class ApiAccessRequest(TimeStampedModel): ...@@ -32,7 +32,7 @@ class ApiAccessRequest(TimeStampedModel):
(DENIED, _('Denied')), (DENIED, _('Denied')),
(APPROVED, _('Approved')), (APPROVED, _('Approved')),
) )
user = models.OneToOneField(User) user = models.OneToOneField(User, related_name='api_access_request')
status = models.CharField( status = models.CharField(
max_length=255, max_length=255,
choices=STATUS_CHOICES, choices=STATUS_CHOICES,
......
"""Factories for API management.""" """Factories for API management."""
import factory import factory
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from oauth2_provider.models import get_application_model
from microsite_configuration.tests.factories import SiteFactory from microsite_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
Application = get_application_model() # pylint: disable=invalid-name
class ApiAccessRequestFactory(DjangoModelFactory): class ApiAccessRequestFactory(DjangoModelFactory):
"""Factory for ApiAccessRequest objects.""" """Factory for ApiAccessRequest objects."""
class Meta(object): class Meta(object):
...@@ -14,3 +18,12 @@ class ApiAccessRequestFactory(DjangoModelFactory): ...@@ -14,3 +18,12 @@ class ApiAccessRequestFactory(DjangoModelFactory):
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
site = factory.SubFactory(SiteFactory) site = factory.SubFactory(SiteFactory)
class ApplicationFactory(DjangoModelFactory):
"""Factory for OAuth Application objects."""
class Meta(object):
model = Application
authorization_grant_type = Application.GRANT_CLIENT_CREDENTIALS
client_type = Application.CLIENT_CONFIDENTIAL
#pylint: disable=missing-docstring #pylint: disable=missing-docstring
import unittest import unittest
import ddt
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from oauth2_provider.models import get_application_model
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory, ApplicationFactory
from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
Application = get_application_model() # pylint: disable=invalid-name
class ApiAdminTest(TestCase): class ApiAdminTest(TestCase):
def setUp(self): def setUp(self):
...@@ -86,6 +92,8 @@ class ApiRequestViewTest(ApiAdminTest): ...@@ -86,6 +92,8 @@ class ApiRequestViewTest(ApiAdminTest):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(PLATFORM_NAME='edX')
@ddt.ddt
class ApiRequestStatusViewTest(ApiAdminTest): class ApiRequestStatusViewTest(ApiAdminTest):
def setUp(self): def setUp(self):
...@@ -103,14 +111,35 @@ class ApiRequestStatusViewTest(ApiAdminTest): ...@@ -103,14 +111,35 @@ class ApiRequestStatusViewTest(ApiAdminTest):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertRedirects(response, reverse('api_admin:api-request')) self.assertRedirects(response, reverse('api_admin:api-request'))
def test_get_with_request(self): @ddt.data(
(ApiAccessRequest.APPROVED, 'Your request to access the edX Course Catalog API has been approved.'),
(ApiAccessRequest.PENDING, 'Your request to access the edX Course Catalog API is being processed.'),
(ApiAccessRequest.DENIED, 'Your request to access the edX Course Catalog API has been denied.'),
)
@ddt.unpack
def test_get_with_request(self, status, expected):
""" """
Verify that users who have requested access can see a message Verify that users who have requested access can see a message
regarding their request status. regarding their request status.
""" """
ApiAccessRequestFactory(user=self.user) ApiAccessRequestFactory(user=self.user, status=status)
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(expected, response.content)
def test_get_with_existing_application(self):
"""
Verify that if the user has created their client credentials, they
are shown on the status page.
"""
ApiAccessRequestFactory(user=self.user, status=ApiAccessRequest.APPROVED)
application = ApplicationFactory(user=self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
unicode_content = response.content.decode('utf-8')
self.assertIn(application.client_secret, unicode_content) # pylint: disable=no-member
self.assertIn(application.client_id, unicode_content) # pylint: disable=no-member
self.assertIn(application.redirect_uris, unicode_content) # pylint: disable=no-member
def test_get_anonymous(self): def test_get_anonymous(self):
"""Verify that users must be logged in to see the page.""" """Verify that users must be logged in to see the page."""
...@@ -124,6 +153,49 @@ class ApiRequestStatusViewTest(ApiAdminTest): ...@@ -124,6 +153,49 @@ class ApiRequestStatusViewTest(ApiAdminTest):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@ddt.data(
(ApiAccessRequest.APPROVED, True, True),
(ApiAccessRequest.DENIED, True, False),
(ApiAccessRequest.PENDING, True, False),
(ApiAccessRequest.APPROVED, False, True),
(ApiAccessRequest.DENIED, False, False),
(ApiAccessRequest.PENDING, False, False),
)
@ddt.unpack
def test_post(self, status, application_exists, new_application_created):
"""
Verify that posting the form creates an application if the user is
approved, and does not otherwise. Also ensure that if the user
already has an application, it is deleted before a new
application is created.
"""
if application_exists:
old_application = ApplicationFactory(user=self.user)
ApiAccessRequestFactory(user=self.user, status=status)
self.client.post(self.url, {
'name': 'test.com',
'redirect_uris': 'http://example.com'
})
applications = Application.objects.filter(user=self.user)
if application_exists and new_application_created:
self.assertEqual(applications.count(), 1)
self.assertNotEqual(old_application, applications[0])
elif application_exists:
self.assertEqual(applications.count(), 1)
self.assertEqual(old_application, applications[0])
elif new_application_created:
self.assertEqual(applications.count(), 1)
else:
self.assertEqual(applications.count(), 0)
def test_post_with_errors(self):
ApiAccessRequestFactory(user=self.user, status=ApiAccessRequest.APPROVED)
response = self.client.post(self.url, {
'name': 'test.com',
'redirect_uris': 'not a url'
})
self.assertIn('Enter a valid URL.', response.content)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ApiTosViewTest(ApiAdminTest): class ApiTosViewTest(ApiAdminTest):
......
...@@ -9,13 +9,19 @@ from django.utils.translation import ugettext as _ ...@@ -9,13 +9,19 @@ from django.utils.translation import ugettext as _
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from edxmako.shortcuts import render_to_response from oauth2_provider.generators import generate_client_secret, generate_client_id
from oauth2_provider.models import get_application_model
from oauth2_provider.views import ApplicationRegistration
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.api_admin.decorators import require_api_access
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
Application = get_application_model() # pylint: disable=invalid-name
class ApiRequestView(CreateView): class ApiRequestView(CreateView):
"""Form view for requesting API access.""" """Form view for requesting API access."""
...@@ -38,22 +44,71 @@ class ApiRequestView(CreateView): ...@@ -38,22 +44,71 @@ class ApiRequestView(CreateView):
return super(ApiRequestView, self).form_valid(form) return super(ApiRequestView, self).form_valid(form)
class ApiRequestStatusView(View): class ApiRequestStatusView(ApplicationRegistration):
"""View for confirming our receipt of an API request.""" """View for confirming our receipt of an API request."""
def get(self, request): success_url = reverse_lazy('api_admin:api-status')
def get(self, request, form=None): # pylint: disable=arguments-differ
""" """
If the user has not created an API request, redirect them to the If the user has not created an API request, redirect them to the
request form. Otherwise, display the status of their API request. request form. Otherwise, display the status of their API
request. We take `form` as an optional argument so that we can
display validation errors correctly on the page.
""" """
status = ApiAccessRequest.api_access_status(request.user) if form is None:
if status is None: form = self.get_form_class()()
user = request.user
try:
api_request = ApiAccessRequest.objects.get(user=user)
except ApiAccessRequest.DoesNotExist:
return redirect(reverse('api_admin:api-request')) return redirect(reverse('api_admin:api-request'))
try:
application = Application.objects.get(user=user)
except Application.DoesNotExist:
application = None
# We want to fill in a few fields ourselves, so remove them
# from the form so that the user doesn't see them.
for field in ('client_type', 'client_secret', 'client_id', 'authorization_grant_type'):
form.fields.pop(field)
return render_to_response('api_admin/status.html', { return render_to_response('api_admin/status.html', {
'status': status, 'status': api_request.status,
'api_support_link': _('TODO'), 'api_support_link': settings.API_DOCUMENTATION_URL,
'api_support_email': settings.API_ACCESS_MANAGER_EMAIL, 'api_support_email': settings.API_ACCESS_MANAGER_EMAIL,
'form': form,
'application': application,
})
def get_form(self, form_class=None):
form = super(ApiRequestStatusView, self).get_form(form_class)
# Copy the data, since it's an immutable QueryDict.
copied_data = form.data.copy()
# Now set the fields that were removed earlier. We give them
# confidential client credentials, and generate their client
# ID and secret.
copied_data.update({
'authorization_grant_type': Application.GRANT_CLIENT_CREDENTIALS,
'client_type': Application.CLIENT_CONFIDENTIAL,
'client_secret': generate_client_secret(),
'client_id': generate_client_id(),
}) })
form.data = copied_data
return form
def form_valid(self, form):
# Delete any existing applications if the user has decided to regenerate their credentials
Application.objects.filter(user=self.request.user).delete()
return super(ApiRequestStatusView, self).form_valid(form)
def form_invalid(self, form):
return self.get(self.request, form)
@require_api_access
def post(self, request):
return super(ApiRequestStatusView, self).post(request)
class ApiTosView(TemplateView): class ApiTosView(TemplateView):
......
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