Merge pull request #11982 from edx/peter-fogg/create-api-request

Request API access.
This commit is contained in:
Peter Fogg
2016-04-04 22:56:49 -04:00
21 changed files with 601 additions and 6 deletions

View File

@@ -1,7 +1,8 @@
"""Admin views for API managment."""
from django.contrib import admin
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from config_models.admin import ConfigurationModelAdmin
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
@admin.register(ApiAccessRequest)
@@ -10,3 +11,7 @@ class ApiAccessRequestAdmin(admin.ModelAdmin):
list_display = ('user', 'status', 'website')
list_filter = ('status',)
search_fields = ('user__email',)
raw_id_fields = ('user',)
admin.site.register(ApiAccessConfig, ConfigurationModelAdmin)

View File

@@ -0,0 +1,17 @@
"""Decorators for API access management."""
from functools import wraps
from django.http import HttpResponseNotFound
from openedx.core.djangoapps.api_admin.models import ApiAccessConfig
def api_access_enabled_or_404(view):
"""If API access management feature is not enabled, return a 404."""
@wraps(view)
def wrapped_view(request, *args, **kwargs):
"""Wrapper for the view function."""
if ApiAccessConfig.current().enabled:
return view(request, *args, **kwargs)
return HttpResponseNotFound()
return wrapped_view

View File

@@ -0,0 +1,44 @@
"""Forms for API management."""
from django import forms
from django.conf import settings
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
class ApiAccessRequestForm(forms.ModelForm):
"""Form to request API access."""
terms_of_service = forms.BooleanField(
label=_('{platform_name} API Terms of Service').format(platform_name=settings.PLATFORM_NAME),
help_text=_(
'The resulting Package will still be considered part of Covered Code. Your Grants.'
' In consideration of, and distributed, a Modification is: (a) any addition to or loss'
' of data, programs or other fee is charged for the physical act of transferring a copy,'
' and you may do so by its licensors. The Licensor grants to You for damages, including '
'any direct, indirect, special, incidental and consequential damages, such as lost profits;'
' iii) states that any such claim is resolved (such as deliberate and grossly negligent acts)'
' or agreed to in writing, the Copyright Holder nor by the laws of the Original Code and'
' any other entity based on the same media as an expression of character texts or the whole'
' of the Licensed Product, and (iv) you make to the general goal of allowing unrestricted '
're-use and re-distribute applies to "Community Portal Server" and related software products'
' as well as in related documentation and collateral materials stating that you have modified'
' that component; or it may be copied, modified, distributed, and/or redistributed.'
),
)
class Meta(object):
model = ApiAccessRequest
fields = ('company_name', 'website', 'company_address', 'reason', 'terms_of_service')
labels = {
'reason': _('Describe what your application does.'),
}
help_texts = {
'reason': None,
'website': _("The URL of your company's website."),
'company_name': _('The name of your company.'),
'company_address': _('The contact address of your company.'),
}
widgets = {
'company_address': forms.Textarea()
}

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('api_admin', '0002_auto_20160325_1604'),
]
operations = [
migrations.CreateModel(
name='ApiAccessConfig',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
migrations.AddField(
model_name='apiaccessrequest',
name='company_address',
field=models.CharField(default=b'', max_length=255),
),
migrations.AddField(
model_name='apiaccessrequest',
name='company_name',
field=models.CharField(default=b'', max_length=255),
),
migrations.AddField(
model_name='historicalapiaccessrequest',
name='company_address',
field=models.CharField(default=b'', max_length=255),
),
migrations.AddField(
model_name='historicalapiaccessrequest',
name='company_name',
field=models.CharField(default=b'', max_length=255),
),
migrations.AlterField(
model_name='apiaccessrequest',
name='user',
field=models.OneToOneField(to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -7,6 +7,7 @@ from django.utils.translation import ugettext as _
from django_extensions.db.models import TimeStampedModel
from simple_history.models import HistoricalRecords
from config_models.models import ConfigurationModel
log = logging.getLogger(__name__)
@@ -22,7 +23,7 @@ class ApiAccessRequest(TimeStampedModel):
(DENIED, _('Denied')),
(APPROVED, _('Approved')),
)
user = models.ForeignKey(User)
user = models.OneToOneField(User)
status = models.CharField(
max_length=255,
choices=STATUS_CHOICES,
@@ -32,6 +33,8 @@ class ApiAccessRequest(TimeStampedModel):
)
website = models.URLField(help_text=_('The URL of the website associated with this API user.'))
reason = models.TextField(help_text=_('The reason this user wants to access the API.'))
company_name = models.CharField(max_length=255, default='')
company_address = models.CharField(max_length=255, default='')
history = HistoricalRecords()
@@ -45,7 +48,24 @@ class ApiAccessRequest(TimeStampedModel):
Returns:
bool
"""
return cls.objects.filter(user=user, status=cls.APPROVED).exists()
return cls.api_access_status(user) == cls.APPROVED
@classmethod
def api_access_status(cls, user):
"""
Returns the user's API access status, or None if they have not
requested access.
Arguments:
user (User): The user to check access for.
Returns:
str or None
"""
try:
return cls.objects.get(user=user).status
except cls.DoesNotExist:
return None
def approve(self):
"""Approve this request."""
@@ -61,3 +81,10 @@ class ApiAccessRequest(TimeStampedModel):
def __unicode__(self):
return u'ApiAccessRequest {website} [{status}]'.format(website=self.website, status=self.status)
class ApiAccessConfig(ConfigurationModel):
"""Configuration for API management."""
def __unicode__(self):
return u'ApiAccessConfig [enabled={}]'.format(self.enabled)

View File

@@ -0,0 +1,20 @@
#pylint: disable=missing-docstring
import ddt
from django.test import TestCase
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm
from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA
@ddt.ddt
class ApiAccessFormTest(TestCase):
@ddt.data(
(VALID_DATA, True),
({}, False),
(dict(VALID_DATA, terms_of_service=False), False)
)
@ddt.unpack
def test_form_valid(self, data, is_valid):
form = ApiAccessRequestForm(data)
self.assertEqual(form.is_valid(), is_valid)

View File

@@ -1,8 +1,9 @@
# pylint: disable=missing-docstring
import ddt
from django.db import IntegrityError
from django.test import TestCase
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory
from student.tests.factories import UserFactory
@@ -42,3 +43,29 @@ class ApiAccessRequestTests(TestCase):
self.request.status = status
self.request.save() # pylint: disable=no-member
self.assertEqual(ApiAccessRequest.has_api_access(self.user), should_have_access)
def test_unique_per_user(self):
with self.assertRaises(IntegrityError):
ApiAccessRequestFactory(user=self.user)
def test_no_access(self):
self.request.delete() # pylint: disable=no-member
self.assertIsNone(ApiAccessRequest.api_access_status(self.user))
def test_unicode(self):
request_unicode = unicode(self.request)
self.assertIn(self.request.website, request_unicode) # pylint: disable=no-member
self.assertIn(self.request.status, request_unicode)
class ApiAccessConfigTests(TestCase):
def test_unicode(self):
self.assertEqual(
unicode(ApiAccessConfig(enabled=True)),
u'ApiAccessConfig [enabled=True]'
)
self.assertEqual(
unicode(ApiAccessConfig(enabled=False)),
u'ApiAccessConfig [enabled=False]'
)

View File

@@ -0,0 +1,147 @@
#pylint: disable=missing-docstring
from smtplib import SMTPException
import unittest
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
import mock
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.utils import VALID_DATA
from openedx.core.djangoapps.api_admin.views import log as view_log
from student.tests.factories import UserFactory
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ApiRequestViewTest(TestCase):
def setUp(self):
super(ApiRequestViewTest, self).setUp()
self.url = reverse('api-request')
password = 'abc123'
self.user = UserFactory(password=password)
self.client.login(username=self.user.username, password=password)
ApiAccessConfig(enabled=True).save()
def test_get(self):
"""Verify that a logged-in can see the API request form."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_get_anonymous(self):
"""Verify that users must be logged in to see the page."""
self.client.logout()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
def test_get_with_existing_request(self):
"""
Verify that users who have already requested access are redirected
to the client creation page to see their status.
"""
ApiAccessRequestFactory(user=self.user)
response = self.client.get(self.url)
self.assertRedirects(response, reverse('api-status'))
def _assert_post_success(self, response):
"""
Assert that a successful POST has been made, that the response
redirects correctly, and that the correct object has been created.
"""
self.assertRedirects(response, reverse('api-status'))
api_request = ApiAccessRequest.objects.get(user=self.user)
self.assertEqual(api_request.status, ApiAccessRequest.PENDING)
return api_request
def test_post_valid(self):
"""Verify that a logged-in user can create an API request."""
self.assertFalse(ApiAccessRequest.objects.all().exists())
with mock.patch('openedx.core.djangoapps.api_admin.views.send_mail') as mock_send_mail:
response = self.client.post(self.url, VALID_DATA)
mock_send_mail.assert_called_once_with(
'API access request from ' + VALID_DATA['company_name'],
mock.ANY,
settings.API_ACCESS_FROM_EMAIL,
[settings.API_ACCESS_MANAGER_EMAIL],
fail_silently=False
)
self._assert_post_success(response)
def test_failed_email(self):
"""
Verify that an access request is still created if sending email
fails for some reason, and that the necessary information is
logged.
"""
mail_function = 'openedx.core.djangoapps.api_admin.views.send_mail'
with mock.patch(mail_function, side_effect=SMTPException):
with mock.patch.object(view_log, 'exception') as mock_view_log_exception:
response = self.client.post(self.url, VALID_DATA)
api_request = self._assert_post_success(response)
mock_view_log_exception.assert_called_once_with(
'Error sending API request email for request [%s].', api_request.id # pylint: disable=no-member
)
def test_post_anonymous(self):
"""Verify that users must be logged in to create an access request."""
self.client.logout()
with mock.patch('openedx.core.djangoapps.api_admin.views.send_mail') as mock_send_mail:
response = self.client.post(self.url, VALID_DATA)
mock_send_mail.assert_not_called()
self.assertEqual(response.status_code, 302)
self.assertFalse(ApiAccessRequest.objects.all().exists())
def test_get_with_feature_disabled(self):
"""Verify that the view can be disabled via ApiAccessConfig."""
ApiAccessConfig(enabled=False).save()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 404)
def test_post_with_feature_disabled(self):
"""Verify that the view can be disabled via ApiAccessConfig."""
ApiAccessConfig(enabled=False).save()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 404)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ApiRequestStatusViewTest(TestCase):
def setUp(self):
super(ApiRequestStatusViewTest, self).setUp()
ApiAccessConfig(enabled=True).save()
password = 'abc123'
self.user = UserFactory(password=password)
self.client.login(username=self.user.username, password=password)
self.url = reverse('api-status')
def test_get_without_request(self):
"""
Verify that users who have not yet requested API access are
redirected to the API request form.
"""
response = self.client.get(self.url)
self.assertRedirects(response, reverse('api-request'))
def test_get_with_request(self):
"""
Verify that users who have requested access can see a message
regarding their request status.
"""
ApiAccessRequestFactory(user=self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_get_anonymous(self):
"""Verify that users must be logged in to see the page."""
self.client.logout()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
def test_get_with_feature_disabled(self):
"""Verify that the view can be disabled via ApiAccessConfig."""
ApiAccessConfig(enabled=False).save()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,9 @@
"""Common utilities for tests."""
VALID_DATA = {
'website': 'https://example.com',
'reason': 'I like APIs',
'company_address': '141 Portland Street, Cambridge MA 02139',
'company_name': 'BreadX',
'terms_of_service': True,
}

View File

@@ -0,0 +1,20 @@
"""URLs for API access management."""
from django.conf.urls import url
from django.contrib.auth.decorators import login_required
from openedx.core.djangoapps.api_admin.decorators import api_access_enabled_or_404
from openedx.core.djangoapps.api_admin.views import ApiRequestView, ApiRequestStatusView
urlpatterns = (
url(
r'^status$',
api_access_enabled_or_404(login_required(ApiRequestStatusView.as_view())),
name="api-status"
),
url(
r'$',
api_access_enabled_or_404(login_required(ApiRequestView.as_view())),
name="api-request"
),
)

View File

@@ -0,0 +1,84 @@
"""Views for API management."""
import logging
from smtplib import SMTPException
from django.conf import settings
from django.core.mail import send_mail
from django.core.urlresolvers import reverse_lazy, reverse
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.generic import View
from django.views.generic.edit import CreateView
from edxmako.shortcuts import render_to_response, render_to_string
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
log = logging.getLogger(__name__)
class ApiRequestView(CreateView):
"""Form view for requesting API access."""
form_class = ApiAccessRequestForm
template_name = 'api_admin/api_access_request_form.html'
success_url = reverse_lazy('api-status')
def get(self, request):
"""
If the requesting user has already requested API access, redirect
them to the client creation page.
"""
if ApiAccessRequest.api_access_status(request.user) is not None:
return redirect(reverse('api-status'))
return super(ApiRequestView, self).get(request)
def send_email(self, api_request):
"""
Send an email to settings.API_ACCESS_MANAGER_EMAIL with the
contents of this API access request.
"""
context = {
'approval_url': self.request.build_absolute_uri(
reverse('admin:api_admin_apiaccessrequest_change', args=(api_request.id,))
),
'company_name': api_request.company_name,
'username': api_request.user.username,
'url': api_request.website,
'company_address': api_request.company_address,
'reason': api_request.reason,
}
message = render_to_string('api_admin/email.txt', context)
try:
send_mail(
_('API access request from {company}').format(company=api_request.company_name),
message,
settings.API_ACCESS_FROM_EMAIL,
[settings.API_ACCESS_MANAGER_EMAIL],
fail_silently=False
)
except SMTPException:
log.exception('Error sending API request email for request [%s].', api_request.id)
def form_valid(self, form):
form.instance.user = self.request.user
result = super(ApiRequestView, self).form_valid(form)
self.send_email(form.instance)
return result
class ApiRequestStatusView(View):
"""View for confirming our receipt of an API request."""
def get(self, request):
"""
If the user has not created an API request, redirect them to the
request form. Otherwise, display the status of their API request.
"""
status = ApiAccessRequest.api_access_status(request.user)
if status is None:
return redirect(reverse('api-request'))
return render_to_response('api_admin/status.html', {
'status': status,
'api_support_link': _('TODO'),
'api_support_email': settings.API_ACCESS_MANAGER_EMAIL,
})