Merge pull request #11982 from edx/peter-fogg/create-api-request
Request API access.
This commit is contained in:
@@ -325,6 +325,5 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',)
|
||||
FEATURES['CUSTOM_COURSES_EDX'] = True
|
||||
|
||||
# API access management. Necessary so that django-simple-history
|
||||
# doesn't break when running pre-test migrations.
|
||||
# API access management -- needed for simple-history to run.
|
||||
INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',)
|
||||
|
||||
@@ -797,3 +797,5 @@ CREDENTIALS_GENERATION_ROUTING_KEY = HIGH_PRIORITY_QUEUE
|
||||
# The extended StudentModule history table
|
||||
if FEATURES.get('ENABLE_CSMH_EXTENDED'):
|
||||
INSTALLED_APPS += ('coursewarehistoryextended',)
|
||||
|
||||
API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL')
|
||||
|
||||
@@ -2866,3 +2866,7 @@ DEFAULT_SITE_ID = 1
|
||||
# Cache time out settings
|
||||
# by Comprehensive Theme system
|
||||
THEME_CACHE_TIMEOUT = 30 * 60
|
||||
|
||||
# API access management
|
||||
API_ACCESS_MANAGER_EMAIL = 'api-access@example.com'
|
||||
API_ACCESS_FROM_EMAIL = 'api-requests@example.com'
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
@import 'views/bookmarks';
|
||||
@import 'course/auto-cert';
|
||||
@import 'views/program-list';
|
||||
@import 'views/api-access';
|
||||
|
||||
// app - discussion
|
||||
@import "discussion/utilities/variables";
|
||||
|
||||
@@ -25,6 +25,7 @@ label {
|
||||
|
||||
textarea,
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="tel"] {
|
||||
|
||||
75
lms/static/sass/views/_api-access.scss
Normal file
75
lms/static/sass/views/_api-access.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
#api-access-wrapper {
|
||||
|
||||
#api-access-request-header {
|
||||
@extend %t-title4;
|
||||
margin-bottom: 0;
|
||||
padding: $baseline;
|
||||
@include text-align(left);
|
||||
}
|
||||
|
||||
.request-status {
|
||||
margin: 0 $baseline;
|
||||
padding: $baseline;
|
||||
box-shadow: 0 1px 2px 1px $shadow-l1;
|
||||
|
||||
&.request-pending {
|
||||
border-top: 2px solid $orange;
|
||||
}
|
||||
}
|
||||
|
||||
#api-access-status {
|
||||
@extend %t-copy-base;
|
||||
}
|
||||
|
||||
#api-access-request {
|
||||
|
||||
padding: 0 $baseline $baseline $baseline;
|
||||
|
||||
#api-form-fields {
|
||||
|
||||
li {
|
||||
margin: $baseline 0;
|
||||
|
||||
.helptext {
|
||||
@extend %t-copy-sub1;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
@extend %t-copy-base;
|
||||
display: block;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
@extend %t-copy-base;
|
||||
margin: 0;
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#api-access-submit {
|
||||
@extend %t-copy-base;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background-color: $blue;
|
||||
box-shadow: none;
|
||||
background-image: none;
|
||||
text-shadow: none;
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lms/templates/api_admin/api_access_request_form.html
Normal file
18
lms/templates/api_admin/api_access_request_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
## mako
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="../main.html"/>
|
||||
<%
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
<div id="api-access-wrapper">
|
||||
<h1 id="api-access-request-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1>
|
||||
|
||||
<form action="" method="post" id="api-access-request">
|
||||
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
<ul id="api-form-fields">
|
||||
${form.as_ul() | n}
|
||||
</ul>
|
||||
<input id="api-access-submit" type="submit" value="${_('Request API Access')}" />
|
||||
</form>
|
||||
</div>
|
||||
8
lms/templates/api_admin/email.txt
Normal file
8
lms/templates/api_admin/email.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
## mako
|
||||
We have received the following request to use the Course Discovery API. Please go to ${approval_url} to approve the user.
|
||||
|
||||
Company name: ${company_name}
|
||||
Company contact: ${username}
|
||||
Company URL: ${url}
|
||||
Address: ${company_address}
|
||||
Reason for API usage: ${reason}
|
||||
27
lms/templates/api_admin/status.html
Normal file
27
lms/templates/api_admin/status.html
Normal file
@@ -0,0 +1,27 @@
|
||||
## mako
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="../main.html"/>
|
||||
<%
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
|
||||
from openedx.core.djangolib.markup import Text, HTML
|
||||
%>
|
||||
|
||||
<div id="api-access-wrapper">
|
||||
<h1 id="api-access-request-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1>
|
||||
<div class="request-status request-${status}">
|
||||
% if status == ApiAccessRequest.PENDING:
|
||||
## 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.
|
||||
<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(
|
||||
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>
|
||||
% endif
|
||||
|
||||
## TODO (ECOM-3946): Add status text for 'active' and 'denied', as well as API client creation.
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1000,3 +1000,8 @@ if settings.FEATURES.get('ENABLE_FINANCIAL_ASSISTANCE_FORM'):
|
||||
name='submit_financial_assistance_request'
|
||||
)
|
||||
)
|
||||
|
||||
# URLs for API access management
|
||||
urlpatterns += (
|
||||
url(r'^api-admin/', include('openedx.core.djangoapps.api_admin.urls')),
|
||||
)
|
||||
|
||||
@@ -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