-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add application with LTI Provider (#764)
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
Showing
17 changed files
with
567 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.