Commit 89795191 by Adam Palay

Merge remote-tracking branch 'origin/release'

Conflicts:
	cms/envs/common.py
	lms/envs/common.py
parents 988c814f 9d850eec
......@@ -81,6 +81,9 @@ FEATURES = {
# Hide any Personally Identifiable Information from application logs
'SQUELCH_PII_IN_LOGS': False,
# Toggles embargo functionality
'EMBARGO': False,
}
ENABLE_JASMINE = False
......@@ -99,6 +102,9 @@ sys.path.append(PROJECT_ROOT / 'djangoapps')
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.
......@@ -185,6 +191,8 @@ MIDDLEWARE_CLASSES = (
# Allows us to dark-launch particular languages
'dark_lang.middleware.DarkLangMiddleware',
'embargo.middleware.EmbargoMiddleware',
# Detects user-requested locale from 'accept-language' header in http request
'django.middleware.locale.LocaleMiddleware',
......@@ -467,6 +475,8 @@ INSTALLED_APPS = (
# User preferences
'user_api',
'django_openid_auth',
'embargo',
)
......
......@@ -196,3 +196,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
# This is to disable a test under the common directory that will not pass when run under CMS
FEATURES['DISABLE_RESET_EMAIL_TEST'] = True
# Toggles embargo on for testing
FEATURES['EMBARGO'] = True
......@@ -54,6 +54,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
......
"""
Django admin page for embargo models
"""
from django.contrib import admin
import textwrap
from config_models.admin import ConfigurationModelAdmin
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.forms import EmbargoedCourseForm, EmbargoedStateForm, IPFilterForm
class EmbargoedCourseAdmin(admin.ModelAdmin):
"""Admin for embargoed course ids"""
form = EmbargoedCourseForm
fieldsets = (
(None, {
'fields': ('course_id', 'embargoed'),
'description': textwrap.dedent("""\
Enter a course id in the following box.
Do not enter leading or trailing slashes. There is no need to surround the
course ID with quotes.
Validation will be performed on the course name, and if it is invalid, an
error message will display.
To enable embargos against this course (restrict course access from embargoed
states), check the "Embargoed" box, then click "Save".
""")
}),
)
class EmbargoedStateAdmin(ConfigurationModelAdmin):
"""Admin for embargoed countries"""
form = EmbargoedStateForm
fieldsets = (
(None, {
'fields': ('embargoed_countries',),
'description': textwrap.dedent("""Enter the two-letter ISO-3166-1 Alpha-2
code of the country or countries to embargo in the following box. For help,
see <a href="http://en.wikipedia.org/wiki/ISO_3166-1#Officially_assigned_code_elements">
this list of ISO-3166-1 country codes</a>.
Enter the embargoed country codes separated by a comma. Do not surround with quotes.
""")
}),
)
class IPFilterAdmin(ConfigurationModelAdmin):
"""Admin for blacklisting/whitelisting specific IP addresses"""
form = IPFilterForm
fieldsets = (
(None, {
'fields': ('whitelist', 'blacklist'),
'description': textwrap.dedent("""Enter specific IP addresses to explicitly
whitelist (not block) or blacklist (block) in the appropriate box below.
Separate IP addresses with a comma. Do not surround with quotes.
""")
}),
)
admin.site.register(EmbargoedCourse, EmbargoedCourseAdmin)
admin.site.register(EmbargoedState, EmbargoedStateAdmin)
admin.site.register(IPFilter, IPFilterAdmin)
"""
List of valid ISO 3166-1 Alpha-2 country codes, used for
validating entries on entered country codes on django-admin page.
"""
COUNTRY_CODES = set([
"AC", "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AN", "AO", "AQ", "AR", "AS", "AT",
"AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BM",
"BN", "BO", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG",
"CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CX", "CY", "CZ", "DE",
"DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ER", "ES", "ET", "FI", "FJ", "FK",
"FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN",
"GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU",
"ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP",
"KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
"LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH",
"MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX",
"MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
"OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PT", "PW", "PY",
"QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI",
"SJ", "SK", "SL", "SM", "SN", "SO", "SR", "ST", "SV", "SY", "SZ", "TA", "TC", "TD",
"TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ",
"UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF",
"WS", "YE", "YT", "ZA", "ZM", "ZW"
])
"""
Defines forms for providing validation of embargo admin details.
"""
from django import forms
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.fixtures.country_codes import COUNTRY_CODES
import socket
from xmodule.modulestore.django import modulestore
class EmbargoedCourseForm(forms.ModelForm): # pylint: disable=incomplete-protocol
"""Form providing validation of entered Course IDs."""
class Meta: # pylint: disable=missing-docstring
model = EmbargoedCourse
def clean_course_id(self):
"""Validate the course id"""
course_id = self.cleaned_data["course_id"]
# Try to get the course. If this returns None, it's not a real course
try:
course = modulestore().get_course(course_id)
except ValueError:
msg = 'COURSE NOT FOUND'
msg += u' --- Entered course id was: "{0}". '.format(course_id)
msg += 'Please recheck that you have supplied a valid course id.'
raise forms.ValidationError(msg)
if not course:
msg = 'COURSE NOT FOUND'
msg += u' --- Entered course id was: "{0}". '.format(course_id)
msg += 'Please recheck that you have supplied a valid course id.'
raise forms.ValidationError(msg)
return course_id
class EmbargoedStateForm(forms.ModelForm): # pylint: disable=incomplete-protocol
"""Form validating entry of states to embargo"""
class Meta: # pylint: disable=missing-docstring
model = EmbargoedState
def _is_valid_code(self, code):
"""Whether or not code is a valid country code"""
return code in COUNTRY_CODES
def clean_embargoed_countries(self):
"""Validate the country list"""
embargoed_countries = self.cleaned_data["embargoed_countries"]
if not embargoed_countries:
return ''
error_countries = []
for country in embargoed_countries.split(','):
country = country.strip().upper()
if not self._is_valid_code(country):
error_countries.append(country)
if error_countries:
msg = 'COULD NOT PARSE COUNTRY CODE(S) FOR: {0}'.format(error_countries)
msg += ' Please check the list of country codes and verify your entries.'
raise forms.ValidationError(msg)
return embargoed_countries
class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol
"""Form validating entry of IP addresses"""
class Meta: # pylint: disable=missing-docstring
model = IPFilter
def _is_valid_ipv4(self, address):
"""Whether or not address is a valid ipv4 address"""
try:
# Is this an ipv4 address?
socket.inet_pton(socket.AF_INET, address)
except socket.error:
return False
return True
def _is_valid_ipv6(self, address):
"""Whether or not address is a valid ipv6 address"""
try:
# Is this an ipv6 address?
socket.inet_pton(socket.AF_INET6, address)
except socket.error:
return False
return True
def _valid_ip_addresses(self, addresses):
"""
Checks if a csv string of IP addresses contains valid values.
If not, raises a ValidationError.
"""
if addresses == '':
return ''
error_addresses = []
for addr in addresses.split(','):
address = addr.strip()
if not (self._is_valid_ipv4(address) or self._is_valid_ipv6(address)):
error_addresses.append(address)
if error_addresses:
msg = 'Invalid IP Address(es): {0}'.format(error_addresses)
msg += ' Please fix the error(s) and try again.'
raise forms.ValidationError(msg)
return addresses
def clean_whitelist(self):
"""Validates the whitelist"""
whitelist = self.cleaned_data["whitelist"]
return self._valid_ip_addresses(whitelist)
def clean_blacklist(self):
"""Validates the blacklist"""
blacklist = self.cleaned_data["blacklist"]
return self._valid_ip_addresses(blacklist)
"""
Middleware for embargoing courses.
IMPORTANT NOTE: This code WILL NOT WORK if you have a misconfigured proxy
server. If you are configuring embargo functionality, or if you are
experiencing mysterious problems with embargoing, please check that your
reverse proxy is setting any of the well known client IP address headers (ex.,
HTTP_X_FORWARDED_FOR).
"""
import pygeoip
from django.core.exceptions import MiddlewareNotUsed
from django.conf import settings
from django.shortcuts import redirect
from ipware.ip import get_ip
from util.request import course_id_from_url
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
class EmbargoMiddleware(object):
"""
Middleware for embargoing courses
This is configured by creating ``EmbargoedCourse``, ``EmbargoedState``, and
optionally ``IPFilter`` rows in the database, using the django admin site.
"""
def __init__(self):
# If embargoing is turned off, make this middleware do nothing
if not settings.FEATURES.get('EMBARGO', False):
raise MiddlewareNotUsed()
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 EmbargoedCourse.is_embargoed(course_id):
# If we're having performance issues, add caching here
ip_addr = get_ip(request)
# if blacklisted, immediately fail
if ip_addr in IPFilter.current().blacklist_ips:
return redirect('embargo')
country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
is_embargoed = country_code_from_ip in EmbargoedState.current().embargoed_countries_list
# Fail if country is embargoed and the ip address isn't explicitly whitelisted
if is_embargoed and ip_addr not in IPFilter.current().whitelist_ips:
return redirect('embargo')
# -*- 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 'EmbargoedCourse'
db.create_table('embargo_embargoedcourse', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255, db_index=True)),
('embargoed', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('embargo', ['EmbargoedCourse'])
# Adding model 'EmbargoedState'
db.create_table('embargo_embargoedstate', (
('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)),
))
db.send_create_signal('embargo', ['EmbargoedState'])
# Adding model 'IPFilter'
db.create_table('embargo_ipfilter', (
('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)),
('whitelist', self.gf('django.db.models.fields.TextField')(blank=True)),
('blacklist', self.gf('django.db.models.fields.TextField')(blank=True)),
))
db.send_create_signal('embargo', ['IPFilter'])
def backwards(self, orm):
# Deleting model 'EmbargoedCourse'
db.delete_table('embargo_embargoedcourse')
# Deleting model 'EmbargoedState'
db.delete_table('embargo_embargoedstate')
# Deleting model 'IPFilter'
db.delete_table('embargo_ipfilter')
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.embargoedcourse': {
'Meta': {'object_name': 'EmbargoedCourse'},
'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
'embargoed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'embargo.embargoedstate': {
'Meta': {'object_name': 'EmbargoedState'},
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'embargo.ipfilter': {
'Meta': {'object_name': 'IPFilter'},
'blacklist': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'})
}
}
complete_apps = ['embargo']
\ No newline at end of file
"""
Models for embargoing visits to certain courses by IP address.
WE'RE USING MIGRATIONS!
If you make changes to this model, be sure to create an appropriate migration
file and check it in at the same time as your model changes. To do that,
1. Go to the edx-platform dir
2. ./manage.py lms schemamigration embargo --auto description_of_your_change
3. Add the migration file created in edx-platform/common/djangoapps/embargo/migrations/
"""
from django.db import models
from config_models.models import ConfigurationModel
class EmbargoedCourse(models.Model):
"""
Enable course embargo on a course-by-course basis.
"""
# The course to embargo
course_id = models.CharField(max_length=255, db_index=True, unique=True)
# Whether or not to embargo
embargoed = models.BooleanField(default=False)
@classmethod
def is_embargoed(cls, course_id):
"""
Returns whether or not the given course id is embargoed.
If course has not been explicitly embargoed, returns False.
"""
try:
record = cls.objects.get(course_id=course_id)
return record.embargoed
except cls.DoesNotExist:
return False
def __unicode__(self):
not_em = "Not "
if self.embargoed:
not_em = ""
return u"Course '{}' is {}Embargoed".format(self.course_id, not_em)
class EmbargoedState(ConfigurationModel):
"""
Register countries to be embargoed.
"""
# The countries to embargo
embargoed_countries = models.TextField(
blank=True,
help_text="A comma-separated list of country codes that fall under U.S. embargo restrictions"
)
@property
def embargoed_countries_list(self):
"""
Return a list of upper case country codes
"""
if self.embargoed_countries == '':
return []
return [country.strip().upper() for country in self.embargoed_countries.split(',')] # pylint: disable=no-member
class IPFilter(ConfigurationModel):
"""
Register specific IP addresses to explicitly block or unblock.
"""
whitelist = models.TextField(
blank=True,
help_text="A comma-separated list of IP addresses that should not fall under embargo restrictions."
)
blacklist = models.TextField(
blank=True,
help_text="A comma-separated list of IP addresses that should fall under embargo restrictions."
)
@property
def whitelist_ips(self):
"""
Return a list of valid IP addresses to whitelist
"""
if self.whitelist == '':
return []
return [addr.strip() for addr in self.whitelist.split(',')] # pylint: disable=no-member
@property
def blacklist_ips(self):
"""
Return a list of valid IP addresses to blacklist
"""
if self.blacklist == '':
return []
return [addr.strip() for addr in self.blacklist.split(',')] # pylint: disable=no-member
# -*- coding: utf-8 -*-
"""
Unit tests for embargo app admin forms.
"""
from django.test import TestCase
from django.test.utils import override_settings
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
from embargo.forms import EmbargoedCourseForm, EmbargoedStateForm, IPFilterForm
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class EmbargoCourseFormTest(ModuleStoreTestCase):
"""Test the course form properly validates course IDs"""
def setUp(self):
self.course = CourseFactory.create()
self.true_form_data = {'course_id': self.course.id, 'embargoed': True}
self.false_form_data = {'course_id': self.course.id, 'embargoed': False}
def test_embargo_course(self):
self.assertFalse(EmbargoedCourse.is_embargoed(self.course.id))
# Test adding embargo to this course
form = EmbargoedCourseForm(data=self.true_form_data)
# Validation should work
self.assertTrue(form.is_valid())
form.save()
# Check that this course is embargoed
self.assertTrue(EmbargoedCourse.is_embargoed(self.course.id))
def test_repeat_course(self):
# Initially course shouldn't be authorized
self.assertFalse(EmbargoedCourse.is_embargoed(self.course.id))
# Test authorizing the course, which should totally work
form = EmbargoedCourseForm(data=self.true_form_data)
# Validation should work
self.assertTrue(form.is_valid())
form.save()
# Check that this course is authorized
self.assertTrue(EmbargoedCourse.is_embargoed(self.course.id))
# Now make a new course authorization with the same course id that tries to turn email off
form = EmbargoedCourseForm(data=self.false_form_data)
# Validation should not work because course_id field is unique
self.assertFalse(form.is_valid())
self.assertEquals(
"Embargoed course with this Course id already exists.",
form._errors['course_id'][0] # pylint: disable=protected-access
)
with self.assertRaisesRegexp(ValueError, "The EmbargoedCourse could not be created because the data didn't validate."):
form.save()
# Course should still be authorized (invalid attempt had no effect)
self.assertTrue(EmbargoedCourse.is_embargoed(self.course.id))
def test_form_typo(self):
# Munge course id
bad_id = self.course.id + '_typo'
form_data = {'course_id': bad_id, 'embargoed': True}
form = EmbargoedCourseForm(data=form_data)
# Validation shouldn't work
self.assertFalse(form.is_valid())
msg = 'COURSE NOT FOUND'
msg += u' --- Entered course id was: "{0}". '.format(bad_id)
msg += 'Please recheck that you have supplied a valid course id.'
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The EmbargoedCourse could not be created because the data didn't validate."):
form.save()
def test_invalid_location(self):
# Munge course id
bad_id = self.course.id.split('/')[-1]
form_data = {'course_id': bad_id, 'embargoed': True}
form = EmbargoedCourseForm(data=form_data)
# Validation shouldn't work
self.assertFalse(form.is_valid())
msg = 'COURSE NOT FOUND'
msg += u' --- Entered course id was: "{0}". '.format(bad_id)
msg += 'Please recheck that you have supplied a valid course id.'
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The EmbargoedCourse could not be created because the data didn't validate."):
form.save()
class EmbargoedStateFormTest(TestCase):
"""Test form for adding new states"""
def setUp(self):
# Explicitly clear the cache, since ConfigurationModel relies on the cache
cache.clear()
def tearDown(self):
# Explicitly clear ConfigurationModel's cache so tests have a clear cache
# and don't interfere with each other
cache.clear()
def test_add_valid_states(self):
# test adding valid two letter states
# case and spacing should not matter
form_data = {'embargoed_countries': 'cu, Sy , US'}
form = EmbargoedStateForm(data=form_data)
self.assertTrue(form.is_valid())
form.save()
current_embargoes = EmbargoedState.current().embargoed_countries_list
for country in ["CU", "SY", "US"]:
self.assertIn(country, current_embargoes)
# Test clearing by adding an empty list is OK too
form_data = {'embargoed_countries': ''}
form = EmbargoedStateForm(data=form_data)
self.assertTrue(form.is_valid())
form.save()
self.assertTrue(len(EmbargoedState.current().embargoed_countries_list) == 0)
def test_add_invalid_states(self):
# test adding invalid codes
# xx is not valid
# usa is not valid
form_data = {'embargoed_countries': 'usa, xx'}
form = EmbargoedStateForm(data=form_data)
self.assertFalse(form.is_valid())
msg = 'COULD NOT PARSE COUNTRY CODE(S) FOR: {0}'.format([u'USA', u'XX'])
msg += ' Please check the list of country codes and verify your entries.'
self.assertEquals(msg, form._errors['embargoed_countries'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The EmbargoedState could not be created because the data didn't validate."):
form.save()
self.assertFalse('USA' in EmbargoedState.current().embargoed_countries_list)
self.assertFalse('XX' in EmbargoedState.current().embargoed_countries_list)
class IPFilterFormTest(TestCase):
"""Test form for adding [black|white]list IP addresses"""
def tearDown(self):
# Explicitly clear ConfigurationModel's cache so tests have a clear cache
# and don't interfere with each other
cache.clear()
def test_add_valid_ips(self):
# test adding valid ip addresses
# should be able to do both ipv4 and ipv6
# spacing should not matter
form_data = {
'whitelist': '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101',
'blacklist': ' 18.244.1.5 , 2002:c0a8:101::42, 18.36.22.1'
}
form = IPFilterForm(data=form_data)
self.assertTrue(form.is_valid())
form.save()
whitelist = IPFilter.current().whitelist_ips
blacklist = IPFilter.current().blacklist_ips
for addr in '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101'.split(','):
self.assertIn(addr.strip(), whitelist)
for addr in '18.244.1.5, 2002:c0a8:101::42, 18.36.22.1'.split(','):
self.assertIn(addr.strip(), blacklist)
# Test clearing by adding an empty list is OK too
form_data = {
'whitelist': '',
'blacklist': ''
}
form = IPFilterForm(data=form_data)
self.assertTrue(form.is_valid())
form.save()
self.assertTrue(len(IPFilter.current().whitelist) == 0)
self.assertTrue(len(IPFilter.current().blacklist) == 0)
def test_add_invalid_ips(self):
# test adding invalid ip addresses
form_data = {
'whitelist': '.0.0.1, :dead:beef:::',
'blacklist': ' 18.244.* , 999999:c0a8:101::42'
}
form = IPFilterForm(data=form_data)
self.assertFalse(form.is_valid())
wmsg = "Invalid IP Address(es): [u'.0.0.1', u':dead:beef:::'] Please fix the error(s) and try again."
self.assertEquals(wmsg, form._errors['whitelist'][0]) # pylint: disable=protected-access
bmsg = "Invalid IP Address(es): [u'18.244.*', u'999999:c0a8:101::42'] Please fix the error(s) and try again."
self.assertEquals(bmsg, form._errors['blacklist'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The IPFilter could not be created because the data didn't validate."):
form.save()
"""
Tests for EmbargoMiddleware
"""
import mock
import pygeoip
import unittest
from django.conf import settings
from django.test import TestCase, Client
from django.test.utils import override_settings
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
@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'
EmbargoedCourse(course_id=self.embargo_course.id, embargoed=True).save()
EmbargoedState(
embargoed_countries="cu, ir, Sy, SD",
changed_by=self.user,
enabled=True
).save()
CourseEnrollment.enroll(self.user, self.regular_course.id)
CourseEnrollment.enroll(self.user, self.embargo_course.id)
# Text from lms/templates/static_templates/embargo.html
self.embargo_text = "Unfortunately, at this time edX must comply with export controls, and we cannot allow you to access this particular course."
self.patcher = mock.patch.object(pygeoip.GeoIP, 'country_code_by_addr', self.mock_country_code_by_addr)
self.patcher.start()
def tearDown(self):
# Explicitly clear ConfigurationModel's cache so tests have a clear cache
# and don't interfere with each other
cache.clear()
self.patcher.stop()
def mock_country_code_by_addr(self, ip_addr):
"""
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',
'5.0.0.0': 'AQ', # Antartica
}
return ip_dict.get(ip_addr, 'US')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_countries(self):
# 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)
# Following the redirect should give us the embargo page
response = self.client.get(
self.embargoed_page,
HTTP_X_FORWARDED_FOR='1.0.0.0',
REMOTE_ADDR='1.0.0.0',
follow=True
)
self.assertIn(self.embargo_text, response.content)
# Accessing a regular page 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, 200)
# Accessing an embargoed page from a non-embargoed IP should succeed
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, 200)
# Accessing a regular page from a non-embargoed IP 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, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_ip_exceptions(self):
# Explicitly whitelist/blacklist some IPs
IPFilter(
whitelist='1.0.0.0',
blacklist='5.0.0.0',
changed_by=self.user,
enabled=True
).save()
# Accessing an embargoed page from a blocked IP that's been whitelisted
# should succeed
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, 200)
# Accessing a regular course from a blocked IP that's been whitelisted 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, 200)
# Accessing an embargoed course from non-embargoed IP that's been blacklisted
# should cause a redirect
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, 302)
# Following the redirect should give us the embargo page
response = self.client.get(
self.embargoed_page,
HTTP_X_FORWARDED_FOR='5.0.0.0',
REMOTE_ADDR='1.0.0.0',
follow=True
)
self.assertIn(self.embargo_text, response.content)
# Accessing a regular course from a non-embargoed IP that's been blacklisted 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, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False})
def test_countries_embargo_off(self):
# When the middleware is turned off, all requests should go through
# Accessing an embargoed page from a blocked IP OK
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, 200)
# Accessing a regular page 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, 200)
# Explicitly whitelist/blacklist some IPs
IPFilter(
whitelist='1.0.0.0',
blacklist='5.0.0.0',
changed_by=self.user,
enabled=True
).save()
# Accessing an embargoed course from non-embargoed IP that's been blacklisted
# should be OK
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, 200)
# Accessing a regular course from a non-embargoed IP that's been blacklisted 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, 200)
"""Test of models for embargo middleware app"""
from django.test import TestCase
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
class EmbargoModelsTest(TestCase):
"""Test each of the 3 models in embargo.models"""
def test_course_embargo(self):
course_id = 'abc/123/doremi'
# Test that course is not authorized by default
self.assertFalse(EmbargoedCourse.is_embargoed(course_id))
# Authorize
cauth = EmbargoedCourse(course_id=course_id, embargoed=True)
cauth.save()
# Now, course should be embargoed
self.assertTrue(EmbargoedCourse.is_embargoed(course_id))
self.assertEquals(
cauth.__unicode__(),
"Course 'abc/123/doremi' is Embargoed"
)
# Unauthorize by explicitly setting email_enabled to False
cauth.embargoed = False
cauth.save()
# Test that course is now unauthorized
self.assertFalse(EmbargoedCourse.is_embargoed(course_id))
self.assertEquals(
cauth.__unicode__(),
"Course 'abc/123/doremi' is Not Embargoed"
)
def test_state_embargo(self):
# Azerbaijan and France should not be blocked
good_states = ['AZ', 'FR']
# Gah block USA and Antartica
blocked_states = ['US', 'AQ']
currently_blocked = EmbargoedState.current().embargoed_countries_list
for state in blocked_states + good_states:
self.assertFalse(state in currently_blocked)
# Block
cauth = EmbargoedState(embargoed_countries='US, AQ')
cauth.save()
currently_blocked = EmbargoedState.current().embargoed_countries_list
for state in good_states:
self.assertFalse(state in currently_blocked)
for state in blocked_states:
self.assertTrue(state in currently_blocked)
# Change embargo - block Isle of Man too
blocked_states.append('IM')
cauth.embargoed_countries = 'US, AQ, IM'
cauth.save()
currently_blocked = EmbargoedState.current().embargoed_countries_list
for state in good_states:
self.assertFalse(state in currently_blocked)
for state in blocked_states:
self.assertTrue(state in currently_blocked)
def test_ip_blocking(self):
whitelist = '127.0.0.1'
blacklist = '18.244.51.3'
cwhitelist = IPFilter.current().whitelist_ips
self.assertFalse(whitelist in cwhitelist)
cblacklist = IPFilter.current().blacklist_ips
self.assertFalse(blacklist in cblacklist)
IPFilter(whitelist=whitelist, blacklist=blacklist).save()
cwhitelist = IPFilter.current().whitelist_ips
self.assertTrue(whitelist in cwhitelist)
cblacklist = IPFilter.current().blacklist_ips
self.assertTrue(blacklist in cblacklist)
......@@ -142,6 +142,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:
......@@ -723,7 +732,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):
......
"""Generates common contexts"""
import re
import logging
from xmodule.course_module import CourseDescriptor
from util.request import COURSE_REGEX
COURSE_REGEX = re.compile(r'^.*?/courses/(?P<course_id>[^/]+/[^/]+/[^/]+)')
log = logging.getLogger(__name__)
......
""" Utility functions related to HTTP requests """
import re
from django.conf import settings
from microsite_configuration.middleware import MicrositeConfiguration
COURSE_REGEX = re.compile(r'^.*?/courses/(?P<course_id>[^/]+/[^/]+/[^/]+)')
def safe_get_host(request):
"""
......@@ -16,3 +20,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
......@@ -255,7 +255,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
count_graded = self.student_data_for_location['count_graded']
count_required = self.student_data_for_location['count_required']
except:
success, response = self.query_data_for_location(self.location)
success, response = self.query_data_for_location(self.link_to_location)
if not success:
log.exception(
"No instance data found and could not get data from controller for loc {0} student {1}".format(
......@@ -706,6 +706,7 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
closed = module_attr('closed')
get_instance_state = module_attr('get_instance_state')
get_next_submission = module_attr('get_next_submission')
graded = module_attr('graded')
is_student_calibrated = module_attr('is_student_calibrated')
peer_grading = module_attr('peer_grading')
peer_grading_closed = module_attr('peer_grading_closed')
......@@ -715,4 +716,6 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
save_calibration_essay = module_attr('save_calibration_essay')
save_grade = module_attr('save_grade')
show_calibration_essay = module_attr('show_calibration_essay')
use_for_single_location_local = module_attr('use_for_single_location_local')
_find_corresponding_module_for_location = module_attr('_find_corresponding_module_for_location')
import unittest
import json
import logging
from mock import Mock
from mock import Mock, patch
from webob.multidict import MultiDict
from xblock.field_data import DictFieldData
......@@ -78,12 +78,13 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
success, _data = self.peer_grading.query_data_for_location(self.problem_location.url())
self.assertTrue(success)
def test_get_score(self):
def test_get_score_none(self):
"""
Test getting the score
@return:
Test getting the score.
"""
score = self.peer_grading.get_score()
# Score should be None.
self.assertIsNone(score['score'])
def test_get_max_score(self):
......@@ -179,6 +180,56 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
)
)
def test_get_score_success_fails(self):
"""
Test if query_data_for_location not succeed, their score is None.
"""
score_dict = self.get_score(False, 0, 0)
# Score dict should be None.
self.assertIsNone(score_dict)
def test_get_score(self):
"""
Test if the student has graded equal to required submissions,
their score is 1.0.
"""
score_dict = self.get_score(True, 3, 3)
# Score should be 1.0.
self.assertEqual(score_dict["score"], 1.0)
# Testing score after data is stored in student_data_for_location in xmodule.
_score_dict = self.peer_grading.get_score()
# Score should be 1.0.
self.assertEqual(_score_dict["score"], 1.0)
def test_get_score_zero(self):
"""
Test if the student has graded not equal to required submissions,
their score is 0.0.
"""
score_dict = self.get_score(True, 2, 3)
# Score should be 0.0.
self.assertEqual(score_dict["score"], 0.0)
def get_score(self, success, count_graded, count_required):
self.peer_grading.use_for_single_location_local = True
self.peer_grading.graded = True
# Patch for external grading service.
with patch('xmodule.peer_grading_module.PeerGradingModule.query_data_for_location') as mock_query_data_for_location:
mock_query_data_for_location.return_value = (
success,
{"count_graded": count_graded, "count_required": count_required}
)
# Returning score dict.
return self.peer_grading.get_score()
class MockPeerGradingServiceProblemList(MockPeerGradingService):
def get_problem_list(self, course_id, grader_id):
......
......@@ -245,24 +245,28 @@ class VideoModule(VideoFields, XModule):
elif self.sub:
track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
if self.transcript_language in self.transcripts:
transcript_language = self.transcript_language
elif self.sub:
if not self.transcripts:
transcript_language = 'en'
elif self.transcripts:
transcript_language = self.transcripts.keys()[0]
languages = {'en': 'English'}
else:
# this for the case, when for currently selected video,
# there are no translations and English subtitles are not set by instructor.
transcript_language = 'null'
if self.transcript_language in self.transcripts:
transcript_language = self.transcript_language
elif self.sub:
transcript_language = 'en'
else:
transcript_language = sorted(self.transcripts.keys())[0]
languages = {
lang: display
for lang, display in settings.ALL_LANGUAGES
if lang in self.transcripts
}
all_languages = {i[0]: i[1] for i in settings.ALL_LANGUAGES}
languages = {lang: all_languages[lang] for lang in self.transcripts}
if self.sub:
languages.update({'en': 'English'})
if self.sub:
languages['en'] = 'English'
# OrderedDict for easy testing of rendered context in tests
transcript_languages = OrderedDict(sorted(languages.items(), key=itemgetter(1)))
sorted_languages = OrderedDict(sorted(languages.items(), key=itemgetter(1)))
return self.system.render_template('video.html', {
'ajax_url': self.system.ajax_url + '/save_user_state',
......@@ -287,7 +291,7 @@ class VideoModule(VideoFields, XModule):
'yt_test_timeout': 1500,
'yt_test_url': settings.YOUTUBE_TEST_URL,
'transcript_language': transcript_language,
'transcript_languages': json.dumps(transcript_languages),
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations',
})
......
This product includes GeoLite data created by MaxMind, available from
http://www.maxmind.com.
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -208,8 +208,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render('student_view').content
expected_context.update({
'transcript_languages': '{"en": "English"}' if self.item_descriptor.sub else '{}',
'transcript_language': 'en' if self.item_descriptor.sub else json.dumps(None),
'transcript_languages': '{"en": "English"}',
'transcript_language': 'en',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript'
).rstrip('/?') + '/translation',
......
......@@ -221,6 +221,9 @@ FEATURES = {
# Hide any Personally Identifiable Information from application logs
'SQUELCH_PII_IN_LOGS': False,
# Toggle embargo functionality
'EMBARGO': False,
}
# Used for A/B testing
......@@ -259,6 +262,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"
......@@ -703,6 +709,7 @@ MIDDLEWARE_CLASSES = (
# Allows us to dark-launch particular languages
'dark_lang.middleware.DarkLangMiddleware',
'embargo.middleware.EmbargoMiddleware',
# Allows us to set user preferences
# should be after DarkLangMiddleware
......@@ -727,6 +734,7 @@ MIDDLEWARE_CLASSES = (
# for expiring inactive sessions
'session_inactivity_timeout.middleware.SessionInactivityTimeout',
)
############################### Pipeline #######################################
......@@ -1141,6 +1149,8 @@ INSTALLED_APPS = (
# Student Identity Reverification
'reverification',
'embargo',
)
######################### MARKETING SITE ###############################
......
......@@ -40,6 +40,9 @@ FEATURES['ENABLE_SHOPPING_CART'] = True
FEATURES['ENABLE_S3_GRADE_DOWNLOADS'] = True
FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True
# Toggles embargo on for testing
FEATURES['EMBARGO'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
......
<%! 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 access 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,8 @@
-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
-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware
-e git+https://github.com/appliedsec/pygeoip.git@95e69341cebf5a6a9fbf7c4f5439d458898bdc3b#egg=pygeoip
# Our libraries:
-e git+https://github.com/edx/XBlock.git@893cd83dfb24405ce81b07f49c1c2e3053cdc865#egg=XBlock
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment