From b340c2fd9c1848cd527b2143bda58c0dd621f4cc Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 29 Mar 2016 15:17:46 -0400 Subject: [PATCH] Allow users to request access to the Course Discovery API. ECOM-3940 --- cms/envs/test.py | 3 +- lms/envs/aws.py | 2 + lms/envs/common.py | 4 + lms/static/sass/_build-lms.scss | 1 + lms/static/sass/shared/_forms.scss | 1 + lms/static/sass/views/_api-access.scss | 75 +++++++++ .../api_admin/api_access_request_form.html | 18 +++ lms/templates/api_admin/email.txt | 8 + lms/templates/api_admin/status.html | 27 ++++ lms/urls.py | 5 + openedx/core/djangoapps/api_admin/admin.py | 7 +- .../core/djangoapps/api_admin/decorators.py | 17 ++ openedx/core/djangoapps/api_admin/forms.py | 44 ++++++ .../migrations/0003_auto_20160404_1618.py | 55 +++++++ openedx/core/djangoapps/api_admin/models.py | 31 +++- .../djangoapps/api_admin/tests/test_forms.py | 20 +++ .../djangoapps/api_admin/tests/test_models.py | 29 +++- .../djangoapps/api_admin/tests/test_views.py | 147 ++++++++++++++++++ .../core/djangoapps/api_admin/tests/utils.py | 9 ++ openedx/core/djangoapps/api_admin/urls.py | 20 +++ openedx/core/djangoapps/api_admin/views.py | 84 ++++++++++ 21 files changed, 601 insertions(+), 6 deletions(-) create mode 100644 lms/static/sass/views/_api-access.scss create mode 100644 lms/templates/api_admin/api_access_request_form.html create mode 100644 lms/templates/api_admin/email.txt create mode 100644 lms/templates/api_admin/status.html create mode 100644 openedx/core/djangoapps/api_admin/decorators.py create mode 100644 openedx/core/djangoapps/api_admin/forms.py create mode 100644 openedx/core/djangoapps/api_admin/migrations/0003_auto_20160404_1618.py create mode 100644 openedx/core/djangoapps/api_admin/tests/test_forms.py create mode 100644 openedx/core/djangoapps/api_admin/tests/test_views.py create mode 100644 openedx/core/djangoapps/api_admin/tests/utils.py create mode 100644 openedx/core/djangoapps/api_admin/urls.py create mode 100644 openedx/core/djangoapps/api_admin/views.py diff --git a/cms/envs/test.py b/cms/envs/test.py index 23d2f62ea3..dba103cabc 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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',) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 74d8f59a7e..638aae1ede 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -795,3 +795,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') diff --git a/lms/envs/common.py b/lms/envs/common.py index e7894f3896..b601895369 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2856,3 +2856,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' diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index b749e0f28f..8a56051b40 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -59,6 +59,7 @@ @import 'views/bookmarks'; @import 'course/auto-cert'; @import 'views/program-list'; +@import 'views/api-access'; // app - discussion @import "discussion/utilities/variables"; diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index f78362bf3f..74c9d9ffe1 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -25,6 +25,7 @@ label { textarea, input[type="text"], +input[type="url"], input[type="email"], input[type="password"], input[type="tel"] { diff --git a/lms/static/sass/views/_api-access.scss b/lms/static/sass/views/_api-access.scss new file mode 100644 index 0000000000..aa8cdb5b13 --- /dev/null +++ b/lms/static/sass/views/_api-access.scss @@ -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; + } + } +} diff --git a/lms/templates/api_admin/api_access_request_form.html b/lms/templates/api_admin/api_access_request_form.html new file mode 100644 index 0000000000..00e61621ba --- /dev/null +++ b/lms/templates/api_admin/api_access_request_form.html @@ -0,0 +1,18 @@ +## mako +<%page expression_filter="h"/> +<%inherit file="../main.html"/> +<% +from django.utils.translation import ugettext as _ +%> + +
+

${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

+ +
+ +
    + ${form.as_ul() | n} +
+ +
+
diff --git a/lms/templates/api_admin/email.txt b/lms/templates/api_admin/email.txt new file mode 100644 index 0000000000..1d665edd82 --- /dev/null +++ b/lms/templates/api_admin/email.txt @@ -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} diff --git a/lms/templates/api_admin/status.html b/lms/templates/api_admin/status.html new file mode 100644 index 0000000000..644fbb3870 --- /dev/null +++ b/lms/templates/api_admin/status.html @@ -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 +%> + +
+

${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

+
+ % 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. +

${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('').format(Text(api_support_link)), + link_end=HTML(''), + api_support_email_link=HTML('{email}').format(email=Text(api_support_email)) + )}

+ % endif + + ## TODO (ECOM-3946): Add status text for 'active' and 'denied', as well as API client creation. + +
+
diff --git a/lms/urls.py b/lms/urls.py index 387982daa2..fe1578f839 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -995,3 +995,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')), +) diff --git a/openedx/core/djangoapps/api_admin/admin.py b/openedx/core/djangoapps/api_admin/admin.py index 13e80f8ae2..b961613331 100644 --- a/openedx/core/djangoapps/api_admin/admin.py +++ b/openedx/core/djangoapps/api_admin/admin.py @@ -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) diff --git a/openedx/core/djangoapps/api_admin/decorators.py b/openedx/core/djangoapps/api_admin/decorators.py new file mode 100644 index 0000000000..e6db2cabbe --- /dev/null +++ b/openedx/core/djangoapps/api_admin/decorators.py @@ -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 diff --git a/openedx/core/djangoapps/api_admin/forms.py b/openedx/core/djangoapps/api_admin/forms.py new file mode 100644 index 0000000000..882e116728 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/forms.py @@ -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() + } diff --git a/openedx/core/djangoapps/api_admin/migrations/0003_auto_20160404_1618.py b/openedx/core/djangoapps/api_admin/migrations/0003_auto_20160404_1618.py new file mode 100644 index 0000000000..916a0c622e --- /dev/null +++ b/openedx/core/djangoapps/api_admin/migrations/0003_auto_20160404_1618.py @@ -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), + ), + ] diff --git a/openedx/core/djangoapps/api_admin/models.py b/openedx/core/djangoapps/api_admin/models.py index 4bc7ebc087..ac2454e80e 100644 --- a/openedx/core/djangoapps/api_admin/models.py +++ b/openedx/core/djangoapps/api_admin/models.py @@ -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) diff --git a/openedx/core/djangoapps/api_admin/tests/test_forms.py b/openedx/core/djangoapps/api_admin/tests/test_forms.py new file mode 100644 index 0000000000..42dddcc2b9 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/tests/test_forms.py @@ -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) diff --git a/openedx/core/djangoapps/api_admin/tests/test_models.py b/openedx/core/djangoapps/api_admin/tests/test_models.py index 97c8c4b2c2..67d2a9c607 100644 --- a/openedx/core/djangoapps/api_admin/tests/test_models.py +++ b/openedx/core/djangoapps/api_admin/tests/test_models.py @@ -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]' + ) diff --git a/openedx/core/djangoapps/api_admin/tests/test_views.py b/openedx/core/djangoapps/api_admin/tests/test_views.py new file mode 100644 index 0000000000..60ab721f98 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/tests/test_views.py @@ -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) diff --git a/openedx/core/djangoapps/api_admin/tests/utils.py b/openedx/core/djangoapps/api_admin/tests/utils.py new file mode 100644 index 0000000000..8c2c2e85cd --- /dev/null +++ b/openedx/core/djangoapps/api_admin/tests/utils.py @@ -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, +} diff --git a/openedx/core/djangoapps/api_admin/urls.py b/openedx/core/djangoapps/api_admin/urls.py new file mode 100644 index 0000000000..2a9f3855a3 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/urls.py @@ -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" + ), +) diff --git a/openedx/core/djangoapps/api_admin/views.py b/openedx/core/djangoapps/api_admin/views.py new file mode 100644 index 0000000000..0fa98498f6 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/views.py @@ -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, + })