From 374e97c15fe45ad5fe6b703b431a9e7a0e9bf4b5 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 25 Mar 2016 14:54:40 -0400 Subject: [PATCH] Admin access for API requests. ECOM-3943 --- cms/envs/test.py | 4 ++ lms/envs/common.py | 3 + openedx/core/djangoapps/api_admin/__init__.py | 0 openedx/core/djangoapps/api_admin/admin.py | 12 ++++ .../api_admin/migrations/0001_initial.py | 55 ++++++++++++++++ .../migrations/0002_auto_20160325_1604.py | 36 +++++++++++ .../api_admin/migrations/__init__.py | 0 openedx/core/djangoapps/api_admin/models.py | 63 +++++++++++++++++++ .../djangoapps/api_admin/tests/__init__.py | 0 .../djangoapps/api_admin/tests/factories.py | 14 +++++ .../djangoapps/api_admin/tests/test_models.py | 44 +++++++++++++ 11 files changed, 231 insertions(+) create mode 100644 openedx/core/djangoapps/api_admin/__init__.py create mode 100644 openedx/core/djangoapps/api_admin/admin.py create mode 100644 openedx/core/djangoapps/api_admin/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/api_admin/migrations/0002_auto_20160325_1604.py create mode 100644 openedx/core/djangoapps/api_admin/migrations/__init__.py create mode 100644 openedx/core/djangoapps/api_admin/models.py create mode 100644 openedx/core/djangoapps/api_admin/tests/__init__.py create mode 100644 openedx/core/djangoapps/api_admin/tests/factories.py create mode 100644 openedx/core/djangoapps/api_admin/tests/test_models.py diff --git a/cms/envs/test.py b/cms/envs/test.py index 554f82cb68..f3523b25fc 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -320,3 +320,7 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ######### custom courses ######### 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. +INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1849c5af87..ddf5ce455c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1992,6 +1992,9 @@ INSTALLED_APPS = ( # Review widgets 'openedx.core.djangoapps.coursetalk', + + # API access administration + 'openedx.core.djangoapps.api_admin', ) # Migrations which are not in the standard module "migrations" diff --git a/openedx/core/djangoapps/api_admin/__init__.py b/openedx/core/djangoapps/api_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/api_admin/admin.py b/openedx/core/djangoapps/api_admin/admin.py new file mode 100644 index 0000000000..13e80f8ae2 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/admin.py @@ -0,0 +1,12 @@ +"""Admin views for API managment.""" +from django.contrib import admin + +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest + + +@admin.register(ApiAccessRequest) +class ApiAccessRequestAdmin(admin.ModelAdmin): + """Admin for API access requests.""" + list_display = ('user', 'status', 'website') + list_filter = ('status',) + search_fields = ('user__email',) diff --git a/openedx/core/djangoapps/api_admin/migrations/0001_initial.py b/openedx/core/djangoapps/api_admin/migrations/0001_initial.py new file mode 100644 index 0000000000..09af2d933b --- /dev/null +++ b/openedx/core/djangoapps/api_admin/migrations/0001_initial.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 +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApiAccessRequest', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('status', models.CharField(default=b'pending', help_text='Status of this API access request', max_length=255, db_index=True, choices=[(b'pending', 'Pending'), (b'denied', 'Denied'), (b'approved', 'Approved')])), + ('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.')), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='HistoricalApiAccessRequest', + fields=[ + ('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('status', models.CharField(default=b'pending', help_text='Status of this API access request', max_length=255, db_index=True, choices=[(b'pending', 'Pending'), (b'denied', 'Denied'), (b'approved', 'Approved')])), + ('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.')), + ('history_id', models.AutoField(serialize=False, primary_key=True)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])), + ('history_user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), + ('user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical api access request', + }, + ), + ] diff --git a/openedx/core/djangoapps/api_admin/migrations/0002_auto_20160325_1604.py b/openedx/core/djangoapps/api_admin/migrations/0002_auto_20160325_1604.py new file mode 100644 index 0000000000..07ad49b5c0 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/migrations/0002_auto_20160325_1604.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +API_GROUP_NAME = 'API Access Request Approvers' + + +def add_api_access_group(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + ApiAccessRequest = apps.get_model('api_admin', 'ApiAccessRequest') + + group, __ = Group.objects.get_or_create(name=API_GROUP_NAME) + api_content_type = ContentType.objects.get_for_model(ApiAccessRequest) + group.permissions = Permission.objects.filter(content_type=api_content_type) + group.save() + + +def delete_api_access_group(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Group.objects.filter(name=API_GROUP_NAME).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api_admin', '0001_initial'), + ('contenttypes', '0002_remove_content_type_name') + ] + + operations = [ + migrations.RunPython(add_api_access_group, delete_api_access_group) + ] diff --git a/openedx/core/djangoapps/api_admin/migrations/__init__.py b/openedx/core/djangoapps/api_admin/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/api_admin/models.py b/openedx/core/djangoapps/api_admin/models.py new file mode 100644 index 0000000000..4bc7ebc087 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/models.py @@ -0,0 +1,63 @@ +"""Models for API management.""" +import logging + +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import ugettext as _ +from django_extensions.db.models import TimeStampedModel +from simple_history.models import HistoricalRecords + + +log = logging.getLogger(__name__) + + +class ApiAccessRequest(TimeStampedModel): + """Model to track API access for a user.""" + + PENDING = 'pending' + DENIED = 'denied' + APPROVED = 'approved' + STATUS_CHOICES = ( + (PENDING, _('Pending')), + (DENIED, _('Denied')), + (APPROVED, _('Approved')), + ) + user = models.ForeignKey(User) + status = models.CharField( + max_length=255, + choices=STATUS_CHOICES, + default=PENDING, + db_index=True, + help_text=_('Status of this API access request'), + ) + 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.')) + + history = HistoricalRecords() + + @classmethod + def has_api_access(cls, user): + """Returns whether or not this user has been granted API access. + + Arguments: + user (User): The user to check access for. + + Returns: + bool + """ + return cls.objects.filter(user=user, status=cls.APPROVED).exists() + + def approve(self): + """Approve this request.""" + log.info('Approving API request from user [%s].', self.user.id) + self.status = self.APPROVED + self.save() + + def deny(self): + """Deny this request.""" + log.info('Denying API request from user [%s].', self.user.id) + self.status = self.DENIED + self.save() + + def __unicode__(self): + return u'ApiAccessRequest {website} [{status}]'.format(website=self.website, status=self.status) diff --git a/openedx/core/djangoapps/api_admin/tests/__init__.py b/openedx/core/djangoapps/api_admin/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/api_admin/tests/factories.py b/openedx/core/djangoapps/api_admin/tests/factories.py new file mode 100644 index 0000000000..942424a91b --- /dev/null +++ b/openedx/core/djangoapps/api_admin/tests/factories.py @@ -0,0 +1,14 @@ +"""Factories for API management.""" +import factory +from factory.django import DjangoModelFactory + +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from student.tests.factories import UserFactory + + +class ApiAccessRequestFactory(DjangoModelFactory): + """Factory for ApiAccessRequest objects.""" + class Meta(object): + model = ApiAccessRequest + + user = factory.SubFactory(UserFactory) diff --git a/openedx/core/djangoapps/api_admin/tests/test_models.py b/openedx/core/djangoapps/api_admin/tests/test_models.py new file mode 100644 index 0000000000..97c8c4b2c2 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/tests/test_models.py @@ -0,0 +1,44 @@ +# pylint: disable=missing-docstring +import ddt +from django.test import TestCase + +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory +from student.tests.factories import UserFactory + + +@ddt.ddt +class ApiAccessRequestTests(TestCase): + + def setUp(self): + super(ApiAccessRequestTests, self).setUp() + self.user = UserFactory() + self.request = ApiAccessRequestFactory(user=self.user) + + def test_default_status(self): + self.assertEqual(self.request.status, ApiAccessRequest.PENDING) + self.assertFalse(ApiAccessRequest.has_api_access(self.user)) + + def test_approve(self): + self.request.approve() # pylint: disable=no-member + self.assertEqual(self.request.status, ApiAccessRequest.APPROVED) + + def test_deny(self): + self.request.deny() # pylint: disable=no-member + self.assertEqual(self.request.status, ApiAccessRequest.DENIED) + + def test_nonexistent_request(self): + """Test that users who have not requested API access do not get it.""" + other_user = UserFactory() + self.assertFalse(ApiAccessRequest.has_api_access(other_user)) + + @ddt.data( + (ApiAccessRequest.PENDING, False), + (ApiAccessRequest.DENIED, False), + (ApiAccessRequest.APPROVED, True), + ) + @ddt.unpack + def test_has_access(self, status, should_have_access): + self.request.status = status + self.request.save() # pylint: disable=no-member + self.assertEqual(ApiAccessRequest.has_api_access(self.user), should_have_access)