Allow users to request access to the Course Discovery API.
ECOM-3940
This commit is contained in:
@@ -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)
|
||||
|
||||
17
openedx/core/djangoapps/api_admin/decorators.py
Normal file
17
openedx/core/djangoapps/api_admin/decorators.py
Normal 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
|
||||
44
openedx/core/djangoapps/api_admin/forms.py
Normal file
44
openedx/core/djangoapps/api_admin/forms.py
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
20
openedx/core/djangoapps/api_admin/tests/test_forms.py
Normal file
20
openedx/core/djangoapps/api_admin/tests/test_forms.py
Normal 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)
|
||||
@@ -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]'
|
||||
)
|
||||
|
||||
147
openedx/core/djangoapps/api_admin/tests/test_views.py
Normal file
147
openedx/core/djangoapps/api_admin/tests/test_views.py
Normal 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)
|
||||
9
openedx/core/djangoapps/api_admin/tests/utils.py
Normal file
9
openedx/core/djangoapps/api_admin/tests/utils.py
Normal 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,
|
||||
}
|
||||
20
openedx/core/djangoapps/api_admin/urls.py
Normal file
20
openedx/core/djangoapps/api_admin/urls.py
Normal 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"
|
||||
),
|
||||
)
|
||||
84
openedx/core/djangoapps/api_admin/views.py
Normal file
84
openedx/core/djangoapps/api_admin/views.py
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user