Embargo Middleware feature
Adds configurable middleware in common/djangoapps/embargo that allows specific courses to comply with US Export regulations by embargoing students from specific countries, whilst simultaneously allowing other courses to be freely open to all.
This commit is contained in:
committed by
Sarina Canelake
parent
331a94c124
commit
a7ae152d9b
@@ -97,6 +97,9 @@ sys.path.append(PROJECT_ROOT / 'lib')
|
||||
sys.path.append(COMMON_ROOT / 'djangoapps')
|
||||
sys.path.append(COMMON_ROOT / 'lib')
|
||||
|
||||
# For geolocation ip database
|
||||
GEOIP_PATH = REPO_ROOT / "common/static/data/geoip/GeoIP.dat"
|
||||
|
||||
|
||||
############################# WEB CONFIGURATION #############################
|
||||
# This is where we stick our compiled template files.
|
||||
@@ -195,6 +198,8 @@ MIDDLEWARE_CLASSES = (
|
||||
|
||||
# for expiring inactive sessions
|
||||
'session_inactivity_timeout.middleware.SessionInactivityTimeout',
|
||||
|
||||
'embargo.middleware.EmbargoMiddleware',
|
||||
)
|
||||
|
||||
############# XBlock Configuration ##########
|
||||
@@ -465,6 +470,8 @@ INSTALLED_APPS = (
|
||||
# User preferences
|
||||
'user_api',
|
||||
'django_openid_auth',
|
||||
|
||||
'embargo',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ urlpatterns += patterns(
|
||||
# ajax view that actually does the work
|
||||
url(r'^login_post$', 'student.views.login_user', name='login_post'),
|
||||
url(r'^logout$', 'student.views.logout_user', name='logout'),
|
||||
url(r'^embargo$', 'student.views.embargo', name="embargo"),
|
||||
)
|
||||
|
||||
# restful api
|
||||
|
||||
0
common/djangoapps/embargo/__init__.py
Normal file
0
common/djangoapps/embargo/__init__.py
Normal file
37
common/djangoapps/embargo/middleware.py
Normal file
37
common/djangoapps/embargo/middleware.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Middleware for embargoing courses.
|
||||
"""
|
||||
|
||||
|
||||
from django.shortcuts import redirect
|
||||
from util.request import course_id_from_url
|
||||
from embargo.models import EmbargoConfig
|
||||
from ipware.ip import get_ip
|
||||
import pygeoip
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class EmbargoMiddleware(object):
|
||||
"""
|
||||
Middleware for embargoing courses
|
||||
|
||||
This is configured by creating ``DarkLangConfig`` rows in the database,
|
||||
using the django admin site.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Processes embargo requests
|
||||
"""
|
||||
url = request.path
|
||||
course_id = course_id_from_url(url)
|
||||
|
||||
# If they're trying to access a course that cares about embargoes
|
||||
if course_id in EmbargoConfig.current().embargoed_courses_list:
|
||||
|
||||
# If we're having performance issues, add caching here
|
||||
ip = get_ip(request)
|
||||
country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip)
|
||||
is_embargoed = (country_code_from_ip in EmbargoConfig.current().embargoed_countries_list)
|
||||
if is_embargoed:
|
||||
return redirect('embargo')
|
||||
76
common/djangoapps/embargo/migrations/0001_initial.py
Normal file
76
common/djangoapps/embargo/migrations/0001_initial.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'EmbargoConfig'
|
||||
db.create_table('embargo_embargoconfig', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
|
||||
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('embargoed_countries', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
('embargoed_courses', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
))
|
||||
db.send_create_signal('embargo', ['EmbargoConfig'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'EmbargoConfig'
|
||||
db.delete_table('embargo_embargoconfig')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'embargo.embargoconfig': {
|
||||
'Meta': {'object_name': 'EmbargoConfig'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'embargoed_countries': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'embargoed_courses': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['embargo']
|
||||
39
common/djangoapps/embargo/models.py
Normal file
39
common/djangoapps/embargo/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Models for embargoing countries
|
||||
"""
|
||||
from django.db import models
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
|
||||
|
||||
class EmbargoConfig(ConfigurationModel):
|
||||
"""
|
||||
Configuration for the embargo feature
|
||||
"""
|
||||
embargoed_countries = models.TextField(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of country codes that fall under U.S. embargo restrictions"
|
||||
)
|
||||
|
||||
embargoed_courses = models.TextField(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of course IDs that we are enforcing the embargo for"
|
||||
)
|
||||
|
||||
@property
|
||||
def embargoed_countries_list(self):
|
||||
"""
|
||||
Returns list of embargoed countries
|
||||
"""
|
||||
if not self.embargoed_countries.strip():
|
||||
return []
|
||||
return [country.strip() for country in self.embargoed_countries.split(',')]
|
||||
|
||||
@property
|
||||
def embargoed_courses_list(self):
|
||||
"""
|
||||
Returns list of embargoed courses
|
||||
"""
|
||||
if not self.embargoed_courses.strip():
|
||||
return []
|
||||
return [course.strip() for course in self.embargoed_courses.split(',')]
|
||||
71
common/djangoapps/embargo/tests/tests.py
Normal file
71
common/djangoapps/embargo/tests/tests.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Tests for EmbargoMiddleware
|
||||
"""
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from embargo.models import EmbargoConfig
|
||||
from django.test import Client
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
import mock
|
||||
import pygeoip
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class EmbargoMiddlewareTests(TestCase):
|
||||
"""
|
||||
Tests of EmbargoMiddleware
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.user = UserFactory(username='fred', password='secret')
|
||||
self.client.login(username='fred', password='secret')
|
||||
self.embargo_course = CourseFactory.create()
|
||||
self.embargo_course.save()
|
||||
self.regular_course = CourseFactory.create(org="Regular")
|
||||
self.regular_course.save()
|
||||
self.embargoed_page = '/courses/' + self.embargo_course.id + '/info'
|
||||
self.regular_page = '/courses/' + self.regular_course.id + '/info'
|
||||
EmbargoConfig(
|
||||
embargoed_countries="CU, IR, SY,SD",
|
||||
embargoed_courses=self.embargo_course.id,
|
||||
changed_by=self.user,
|
||||
enabled=True
|
||||
).save()
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.regular_course.id)
|
||||
CourseEnrollment.enroll(self.user, self.embargo_course.id)
|
||||
|
||||
def test_countries(self):
|
||||
def mock_country_code_by_addr(ip):
|
||||
"""
|
||||
Gives us a fake set of IPs
|
||||
"""
|
||||
ip_dict = {
|
||||
'1.0.0.0': 'CU',
|
||||
'2.0.0.0': 'IR',
|
||||
'3.0.0.0': 'SY',
|
||||
'4.0.0.0': 'SD',
|
||||
}
|
||||
return ip_dict.get(ip, 'US')
|
||||
|
||||
with mock.patch.object(pygeoip.GeoIP, 'country_code_by_addr') as mocked_method:
|
||||
mocked_method.side_effect = mock_country_code_by_addr
|
||||
|
||||
# Accessing an embargoed page from a blocked IP should cause a redirect
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
# Accessing a regular course from a blocked IP should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Accessing any course from non-embaroged IPs should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -140,6 +140,15 @@ def _get_date_for_press(publish_date):
|
||||
return date
|
||||
|
||||
|
||||
def embargo(request):
|
||||
"""
|
||||
Render the embargo page.
|
||||
|
||||
Explains to the user why they are not able to access a particular embargoed course.
|
||||
"""
|
||||
return render_to_response('static_templates/embargo.html')
|
||||
|
||||
|
||||
def press(request):
|
||||
json_articles = cache.get("student_press_json_articles")
|
||||
if json_articles is None:
|
||||
@@ -718,7 +727,7 @@ def login_user(request, error=""):
|
||||
# This is actually the common case, logging in user without external linked login
|
||||
AUDIT_LOG.info("User %s w/o external auth attempting login", user)
|
||||
|
||||
# see if account has been locked out due to excessive login failres
|
||||
# see if account has been locked out due to excessive login failures
|
||||
user_found_by_email_lookup = user
|
||||
if user_found_by_email_lookup and LoginFailures.is_feature_enabled():
|
||||
if LoginFailures.is_user_locked_out(user_found_by_email_lookup):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
""" Utility functions related to HTTP requests """
|
||||
from django.conf import settings
|
||||
from microsite_configuration.middleware import MicrositeConfiguration
|
||||
from track.contexts import COURSE_REGEX
|
||||
|
||||
|
||||
def safe_get_host(request):
|
||||
@@ -16,3 +17,17 @@ def safe_get_host(request):
|
||||
return request.get_host()
|
||||
else:
|
||||
return MicrositeConfiguration.get_microsite_configuration_value('site_domain', settings.SITE_NAME)
|
||||
|
||||
|
||||
def course_id_from_url(url):
|
||||
"""
|
||||
Extracts the course_id from the given `url`.
|
||||
"""
|
||||
url = url or ''
|
||||
|
||||
match = COURSE_REGEX.match(url)
|
||||
course_id = ''
|
||||
if match:
|
||||
course_id = match.group('course_id') or ''
|
||||
|
||||
return course_id
|
||||
|
||||
BIN
common/static/data/geoip/GeoIP.dat
Normal file
BIN
common/static/data/geoip/GeoIP.dat
Normal file
Binary file not shown.
2
common/static/data/geoip/README
Normal file
2
common/static/data/geoip/README
Normal file
@@ -0,0 +1,2 @@
|
||||
This product includes GeoLite data created by MaxMind, available from
|
||||
http://www.maxmind.com.
|
||||
@@ -256,6 +256,9 @@ node_paths = [
|
||||
]
|
||||
NODE_PATH = ':'.join(node_paths)
|
||||
|
||||
# For geolocation ip database
|
||||
GEOIP_PATH = REPO_ROOT / "common/static/data/geoip/GeoIP.dat"
|
||||
|
||||
|
||||
# Where to look for a status message
|
||||
STATUS_MESSAGE_PATH = ENV_ROOT / "status_message.json"
|
||||
@@ -724,6 +727,8 @@ MIDDLEWARE_CLASSES = (
|
||||
|
||||
# for expiring inactive sessions
|
||||
'session_inactivity_timeout.middleware.SessionInactivityTimeout',
|
||||
|
||||
'embargo.middleware.EmbargoMiddleware',
|
||||
)
|
||||
|
||||
############################### Pipeline #######################################
|
||||
@@ -1138,6 +1143,8 @@ INSTALLED_APPS = (
|
||||
|
||||
# Student Identity Reverification
|
||||
'reverification',
|
||||
|
||||
'embargo',
|
||||
)
|
||||
|
||||
######################### MARKETING SITE ###############################
|
||||
|
||||
8
lms/templates/static_templates/embargo.html
Normal file
8
lms/templates/static_templates/embargo.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="pagetitle">${_("This Course Unavailable In Your Country")}</%block>
|
||||
|
||||
<section class="outside-app">
|
||||
<p>${_('Our system indicates that you are trying to access an edX course from an IP address associated with a country currently subjected to U.S. economic and trade sanctions. Unfortunately, at this time edX must comply with export controls, and we cannot allow you to register for this particular course. Feel free to browse our catalogue to find other courses you may be interested in taking.')}</p>
|
||||
</section>
|
||||
@@ -11,7 +11,6 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
|
||||
|
||||
urlpatterns = ('', # nopep8
|
||||
# certificate view
|
||||
|
||||
url(r'^update_certificate$', 'certificates.views.update_certificate'),
|
||||
url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
|
||||
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
|
||||
@@ -66,6 +65,8 @@ urlpatterns = ('', # nopep8
|
||||
url(r'^', include('waffle.urls')),
|
||||
|
||||
url(r'^i18n/', include('django.conf.urls.i18n')),
|
||||
|
||||
url(r'^embargo$', 'student.views.embargo', name="embargo"),
|
||||
)
|
||||
|
||||
# if settings.FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"):
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
-e git+https://github.com/gabrielfalcao/lettuce.git@cccc3978ad2df82a78b6f9648fe2e9baddd22f88#egg=lettuce
|
||||
-e git+https://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev
|
||||
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
|
||||
# TODO clear this library with appropriate people
|
||||
-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware
|
||||
# TODO clear this library with appropriate people
|
||||
-e git+https://github.com/appliedsec/pygeoip.git@95e69341cebf5a6a9fbf7c4f5439d458898bdc3b#egg=pygeoip
|
||||
|
||||
# Our libraries:
|
||||
-e git+https://github.com/edx/XBlock.git@893cd83dfb24405ce81b07f49c1c2e3053cdc865#egg=XBlock
|
||||
|
||||
Reference in New Issue
Block a user