Skip to content

Commit

Permalink
Add application with LTI Provider (#764)
Browse files Browse the repository at this point in the history
Create lti_provider application with the base provider's logic

lti/launch/course/<course_id> -- specifying the course
lti/launch/course/<course_id>/assignment/<assignment_id> -- specifying
certain assignment in the course
lti/launch/course/<course_id>/assignment/<assignment_id>/\
experiment -- open experiment in the assignment
  • Loading branch information
Igor Degtiarov authored Jul 31, 2017
1 parent 9a79a55 commit 434d7f2
Show file tree
Hide file tree
Showing 17 changed files with 567 additions and 5 deletions.
18 changes: 16 additions & 2 deletions StarCellBio/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@
'django.contrib.admindocs',
'backend',
'instructor',
'storages'
'storages',
'lti_provider',
) + auth.settings.INSTALLED_APPS

# django all-auth config
Expand Down Expand Up @@ -210,6 +211,7 @@
ADMINS = tuple(tuple(admin) for admin in ADMINS)

HOSTNAME = platform.node().split('.')[0]

LOGGING = {
'version': 1,
'disable_existing_loggers': True,
Expand Down Expand Up @@ -267,6 +269,18 @@
},
'urllib3': {
'level': 'INFO',
}
},
'lti_provider.views': {
'level': LOG_LEVEL,
'handlers': ['console', 'syslog'],
},
'lti_provider.validator': {
'level': 'DEBUG',
'handlers': ['console', 'syslog'],
},
'lti_provider.models': {
'level': 'DEBUG',
'handlers': ['console', 'syslog'],
},
},
}
3 changes: 3 additions & 0 deletions StarCellBio/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@
'document_root': 'html_app/js/'
}
),

# Uncomment the lti_provider line below to enable LTI provider:
url(r'^lti/', include('lti_provider.urls', namespace='lti')),
)
# add authentication URL patterns
urlpatterns += auth.urls.urlpatterns
Expand Down
3 changes: 2 additions & 1 deletion frontend_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ def tearDown(self):
super(SimpleTest, self).tearDown()

def setUp(self):
User.objects.create_user(username='test', password='test')
# NOTE(idegtiarov) Add User id which is used as default for new assignment created
User.objects.create_user(username='test', password='test', id=1)

def _setup_experiment(self, number=3, samples=tuple()):
"""
Expand Down
Empty file added lti_provider/__init__.py
Empty file.
11 changes: 11 additions & 0 deletions lti_provider/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.contrib import admin

from lti_provider.models import LTIUser, Consumer


class ConsumerAdmin(admin.ModelAdmin):
search_fields = ('consumer_name', 'consumer_key', 'expiration_date')


admin.site.register(Consumer, ConsumerAdmin)
admin.site.register(LTIUser)
8 changes: 8 additions & 0 deletions lti_provider/lti_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.conf import settings


# Switch on debug mode for LTI requests
DEBUG_LTI = getattr(settings, 'DEBUG_LTI', True)

# Uncomment to switch off LTI ssl requirements (only for development mode)
#LTI_SSL = getattr(settings, 'LTI_SSL', False)
69 changes: 69 additions & 0 deletions lti_provider/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from django.contrib.auth import login
from django.contrib.auth.models import User
from django.db import models

from auth import forms
from auth.course import create_course_records
from lti_provider.utils import key_secret_generator, hash_lti_username

import logging

logger = logging.getLogger(__name__)


class Consumer(models.Model):
"""
Model to manage LTI consumers
"""
consumer_name = models.CharField(max_length=255, unique=True)
consumer_key = models.CharField(max_length=30, unique=True, default=key_secret_generator)
consumer_secret = models.CharField(max_length=30, unique=True, default=key_secret_generator)
expiration_date = models.DateField(verbose_name='Consumer key expiration date', null=True, blank=True)

class Meta:
verbose_name = "LTI Consumer"
verbose_name_plural = "LTI Consumers"

def __unicode__(self):
return self.consumer_name


class LTIUser(models.Model):
"""
Model to manage LTI users
"""
user_id = models.CharField(max_length=255, blank=False)
consumer = models.ForeignKey(Consumer, null=True)
scb_user = models.ForeignKey(User, null=True, related_name='lti_user', on_delete=models.CASCADE)

class Meta: # pragma: no cover
verbose_name = "LTI User"
verbose_name_plural = "LTI Users"
unique_together = ('user_id', 'consumer')

@property
def is_scb_user(self):
return bool(self.scb_user)

def lti_to_scb_user(self, roles, course_id):
"""
Connecting LTI user with the StarCellBio user account
:param roles: list of the strings describing assigned roles
"""
username = hash_lti_username(self.user_id)
scb_user, _ = User.objects.get_or_create(username=username)
self.scb_user = scb_user
for role in roles:
forms.add_to_group(scb_user, role)
self.save()
create_course_records(scb_user, course_id)

def login(self, request):
"""
Login connected SCB user
"""
if self.scb_user:
self.scb_user.backend = 'allauth.account.auth_backends.AuthenticationBackend'
logger.debug("Start User {} login process...".format(self.scb_user.username))
login(request, self.scb_user)
logger.debug("Check User is authenticated: {}".format(request.user.is_authenticated()))
Empty file.
84 changes: 84 additions & 0 deletions lti_provider/unit_tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse
from django.test import TestCase, RequestFactory

from backend.models import Course, UserCourse
from lti_provider.models import Consumer, LTIUser


class TestConsumer(TestCase):
"""
Testing LTI Consumer model
"""

def test_new_consumer_key_and_secret(self):
"""
Tests new consumer creates with autocomplete key and secret
"""
new_consumer = Consumer.objects.create(consumer_name='TestKeySecret')
self.assertTrue(new_consumer.consumer_key, msg='Consumer key was not autocompleted.')
self.assertTrue(new_consumer.consumer_secret, msg='Consumer secret was not autocompleted.')


class TestLTIUser(TestCase):
"""
Testing LTI User model
"""
@classmethod
def setUpClass(cls):
cls.consumer = Consumer.objects.create(consumer_name='TestLTIUser')
cls.course_code = 'fake_course'
Group.objects.get_or_create(name='student')
Group.objects.get_or_create(name='instructor')
cls.user = User.objects.create()
cls.course = Course.objects.create(code=cls.course_code, ownerID=cls.user)

@classmethod
def tearDownClass(cls):
cls.user.delete()
cls.course.delete()
cls.consumer.delete()

def setUp(self):
self.user = LTIUser.objects.create(user_id='test_user', consumer=self.consumer)

def tearDown(self):
self.user.delete()

def test_lti_to_scb_user_role_student(self):
"""
Tests SCB User is created with student role in the group and creating UserCourse record
"""
roles = ['Student'] # LTI sends list with roles
self.assertFalse(self.user.is_scb_user)
self.user.lti_to_scb_user(roles, self.course_code)
self.assertTrue(self.user.is_scb_user)
self.assertTrue(self.user.scb_user.groups.filter(name='student').exists())
self.assertTrue(UserCourse.objects.filter(course_name=self.course_code, user=self.user.scb_user).exists())

def test_lti_to_scb_user_role_instructor(self):
"""
Tests SCB User is created with instructor role in the group and creating UserCourse record
"""
roles = ['Instructor'] # LTI sends list with roles
self.assertFalse(self.user.is_scb_user)
self.user.lti_to_scb_user(roles, self.course_code)
self.assertTrue(self.user.is_scb_user)
self.assertTrue(self.user.scb_user.groups.filter(name='instructor').exists())
self.assertTrue(UserCourse.objects.filter(course_name=self.course_code, user=self.user.scb_user).exists())

def test_login(self):
"""
Tests login process for SCB User
"""
factory = RequestFactory()
roles = ['Student'] # LTI sends list with roles
self.assertFalse(self.user.is_scb_user)
self.user.lti_to_scb_user(roles, self.course_code)
self.assertTrue(self.user.is_scb_user)
request = factory.post(reverse('home'), content_type='application/json')
request.user = self.user.scb_user
from django.contrib.sessions.middleware import SessionMiddleware
SessionMiddleware().process_request(request)
self.user.login(request)
self.assertTrue(self.user.scb_user.is_authenticated())
58 changes: 58 additions & 0 deletions lti_provider/unit_tests/test_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import datetime

from django.test import TestCase
from oauthlib import oauth1

from lti_provider.models import Consumer
from lti_provider.validator import RequestValidator


class TestRequestValidator(TestCase):
@classmethod
def setUpClass(cls):
cls.key = 'starcellbio201707181230key'
cls.secret = 'verysctonumk201783test_key'
cls.exp_date = datetime.date.today() + datetime.timedelta(days=1)
cls.lti_consumer = Consumer.objects.create(
consumer_name='testLTI',
consumer_key=cls.key,
consumer_secret=cls.secret,
expiration_date=cls.exp_date
)
cls.validator = RequestValidator()

@classmethod
def tearDownClass(cls):
cls.lti_consumer.delete()

def test_check_client_key_valid(self):
is_valid = self.validator.check_client_key(self.key)
self.assertTrue(is_valid, msg='Consumer key is not valid')

def test_check_client_key_invalid(self):
key = 'fake_key'
msg = 'Consumer with the key {} is not found.'.format(key)
try:
self.validator.check_client_key(key)
except oauth1.OAuth1Error as err:
self.assertEqual(err.description, msg)

def test_validate_timestamp_valid(self):
is_valid = self.validator.validate_timestamp_and_nonce(self.key, 'fake_timestamp', 'fake_nonce', 'fake_request')
self.assertTrue(is_valid, msg='Consumer key is expired.')

def test_validate_timestamp_invalid(self):
exp_date = datetime.date.today() - datetime.timedelta(days=1)
self.lti_consumer.expiration_date = exp_date
self.lti_consumer.save()
msg = 'Consumer key {} is expired, expiration date is {}.'.format(self.key, exp_date)
try:
self.validator.validate_timestamp_and_nonce(self.key, 'fake_timestamp', 'fake_nonce', 'fake_request')
except oauth1.OAuth1Error as err:
self.assertEqual(err.description, msg)
self.lti_consumer.expiration_date = self.exp_date
self.lti_consumer.save()

def test_get_client_secret(self):
client_secret = self.validator.get_client_secret(self.key, 'fake_request')
self.assertEqual(client_secret, self.secret)
92 changes: 92 additions & 0 deletions lti_provider/unit_tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from django.contrib.sessions.middleware import SessionMiddleware
from mock import patch, Mock

from django.contrib.auth.models import User, Group, AnonymousUser
from django.core.urlresolvers import reverse
from django.test import TestCase, RequestFactory

from backend.models import Course, Assignment
from lti_provider.models import Consumer, LTIUser
from lti_provider.validator import RequestValidator
from lti_provider.views import lti_launch


def mock_tool_provider():
m = Mock()
m.is_valid_request.return_value = True
return m


class TestLTI_Launch(TestCase):
@classmethod
def setUpClass(cls):
cls.course_code = 'fake_course'
cls.user_id = 'fake_user_id'
cls.consumer_key = 'fake_consumer_key'
cls.assignment_id = 'fake_assignment_id'
cls.user = User.objects.create() # Course creation requires at least one User exists
cls.course = Course.objects.create(code=cls.course_code, ownerID=cls.user)
cls.consumer = Consumer.objects.create(consumer_name='TestLTI_LaunchView', consumer_key=cls.consumer_key)
cls.assignment = Assignment.objects.create(
courseID=cls.course,
assignmentID=cls.assignment_id,
assignmentName='fake_assignment',
ownerID=cls.user,
)
cls.post_data = {
'user_id': cls.user_id,
'oauth_consumer_key': cls.consumer_key,
}
Group.objects.get_or_create(name='student')
Group.objects.get_or_create(name='instructor')

@classmethod
def tearDownClass(cls):
cls.user.delete()
cls.course.delete()
cls.consumer.delete()
cls.assignment.delete()

def setUp(self):
self.factory = RequestFactory()
self.request = self.factory.post(
reverse('lti:launch_course', args=[self.course_code]),
data=self.post_data
)
self.request.user = AnonymousUser()
middleware = SessionMiddleware()
middleware.process_request(self.request)

def tearDown(self):
user = LTIUser.objects.get(user_id=self.user_id)
user.delete()

@patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider())
def test_lti_launch_course_id(self, mock_tool_provider):
response = lti_launch(self.request, self.course_code)
response.client = self.client
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302)
mock_tool_provider.asser_called_once_with(RequestValidator)

@patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider())
def test_lti_launch_course_id_assignment_id(self, mock_tool_provider):
response = lti_launch(self.request, self.course_code, self.assignment_id)
response.client = self.client
self.assertEqual(response.url, '/#view=assignments&assignment_id={}'.format(self.assignment_id))
self.assertEqual(response.status_code, 302)
mock_tool_provider.asser_called_once_with(RequestValidator)

@patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider())
def test_lti_launch_course_id_assignment_id_experiment_id(self, mock_tool_provider):
experiment = 'fake_experiment_id'
response = lti_launch(self.request, self.course_code, self.assignment_id, experiment)
response.client = self.client
self.assertEqual(
response.url,
'/#view=experiment_design&assignment_id={}'.format(
self.assignment_id,
)
)
self.assertEqual(response.status_code, 302)
mock_tool_provider.asser_called_once_with(RequestValidator)
Loading

0 comments on commit 434d7f2

Please sign in to comment.