diff --git a/cms/envs/common.py b/cms/envs/common.py
index 577692ba00..902bd59c1e 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -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',
)
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 37dec4f2ec..1e1adf49e4 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -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
diff --git a/cms/urls.py b/cms/urls.py
index 371b20ac5c..65d19e83f7 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -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
diff --git a/common/djangoapps/embargo/__init__.py b/common/djangoapps/embargo/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/embargo/admin.py b/common/djangoapps/embargo/admin.py
new file mode 100644
index 0000000000..8ff4cc3970
--- /dev/null
+++ b/common/djangoapps/embargo/admin.py
@@ -0,0 +1,64 @@
+"""
+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
+ this list of ISO-3166-1 country codes.
+
+ 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)
diff --git a/common/djangoapps/embargo/fixtures/__init__.py b/common/djangoapps/embargo/fixtures/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/embargo/fixtures/country_codes.py b/common/djangoapps/embargo/fixtures/country_codes.py
new file mode 100644
index 0000000000..cc73615a26
--- /dev/null
+++ b/common/djangoapps/embargo/fixtures/country_codes.py
@@ -0,0 +1,25 @@
+"""
+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"
+])
diff --git a/common/djangoapps/embargo/forms.py b/common/djangoapps/embargo/forms.py
new file mode 100644
index 0000000000..09ea11445b
--- /dev/null
+++ b/common/djangoapps/embargo/forms.py
@@ -0,0 +1,125 @@
+"""
+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)
diff --git a/common/djangoapps/embargo/middleware.py b/common/djangoapps/embargo/middleware.py
new file mode 100644
index 0000000000..2e2cb2b645
--- /dev/null
+++ b/common/djangoapps/embargo/middleware.py
@@ -0,0 +1,54 @@
+"""
+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')
diff --git a/common/djangoapps/embargo/migrations/0001_initial.py b/common/djangoapps/embargo/migrations/0001_initial.py
new file mode 100644
index 0000000000..9516b8985e
--- /dev/null
+++ b/common/djangoapps/embargo/migrations/0001_initial.py
@@ -0,0 +1,114 @@
+# -*- 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
diff --git a/common/djangoapps/embargo/migrations/__init__.py b/common/djangoapps/embargo/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/embargo/models.py b/common/djangoapps/embargo/models.py
new file mode 100644
index 0000000000..4ee6613859
--- /dev/null
+++ b/common/djangoapps/embargo/models.py
@@ -0,0 +1,98 @@
+"""
+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
diff --git a/common/djangoapps/embargo/tests/test_forms.py b/common/djangoapps/embargo/tests/test_forms.py
new file mode 100644
index 0000000000..cea030c23d
--- /dev/null
+++ b/common/djangoapps/embargo/tests/test_forms.py
@@ -0,0 +1,198 @@
+# -*- 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()
diff --git a/common/djangoapps/embargo/tests/test_middleware.py b/common/djangoapps/embargo/tests/test_middleware.py
new file mode 100644
index 0000000000..c3f418c905
--- /dev/null
+++ b/common/djangoapps/embargo/tests/test_middleware.py
@@ -0,0 +1,159 @@
+"""
+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)
diff --git a/common/djangoapps/embargo/tests/test_models.py b/common/djangoapps/embargo/tests/test_models.py
new file mode 100644
index 0000000000..12c66295b8
--- /dev/null
+++ b/common/djangoapps/embargo/tests/test_models.py
@@ -0,0 +1,80 @@
+"""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)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 4dff62cf80..453ea03ca3 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -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):
diff --git a/common/djangoapps/track/contexts.py b/common/djangoapps/track/contexts.py
index 0fb06fb1b1..070ac10ebd 100644
--- a/common/djangoapps/track/contexts.py
+++ b/common/djangoapps/track/contexts.py
@@ -1,12 +1,9 @@
"""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[^/]+/[^/]+/[^/]+)')
log = logging.getLogger(__name__)
diff --git a/common/djangoapps/util/request.py b/common/djangoapps/util/request.py
index fc9c835194..a26059e8a7 100644
--- a/common/djangoapps/util/request.py
+++ b/common/djangoapps/util/request.py
@@ -1,7 +1,11 @@
""" 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[^/]+/[^/]+/[^/]+)')
+
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
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index b16ca81656..11c063e545 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -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')
+
diff --git a/common/lib/xmodule/xmodule/tests/test_peer_grading.py b/common/lib/xmodule/xmodule/tests/test_peer_grading.py
index ebfeaa0693..8445527aea 100644
--- a/common/lib/xmodule/xmodule/tests/test_peer_grading.py
+++ b/common/lib/xmodule/xmodule/tests/test_peer_grading.py
@@ -1,7 +1,7 @@
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):
diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py
index e75b7b4c46..172ab8cc33 100644
--- a/common/lib/xmodule/xmodule/video_module/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module/video_module.py
@@ -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]
- 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'})
+ languages = {
+ lang: display
+ for lang, display in settings.ALL_LANGUAGES
+ if lang in self.transcripts
+ }
+
+ 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',
})
diff --git a/common/static/data/geoip/GeoIP.dat b/common/static/data/geoip/GeoIP.dat
new file mode 100644
index 0000000000..5e38e99bdf
Binary files /dev/null and b/common/static/data/geoip/GeoIP.dat differ
diff --git a/common/static/data/geoip/README b/common/static/data/geoip/README
new file mode 100644
index 0000000000..b32fd89783
--- /dev/null
+++ b/common/static/data/geoip/README
@@ -0,0 +1,2 @@
+This product includes GeoLite data created by MaxMind, available from
+http://www.maxmind.com.
\ No newline at end of file
diff --git a/common/test/db_cache/bok_choy_data.json b/common/test/db_cache/bok_choy_data.json
index c485e0decd..e622c88927 100644
--- a/common/test/db_cache/bok_choy_data.json
+++ b/common/test/db_cache/bok_choy_data.json
@@ -1 +1 @@
-[{"pk": 30, "model": "contenttypes.contenttype", "fields": {"model": "anonymoususerid", "name": "anonymous user id", "app_label": "student"}}, {"pk": 50, "model": "contenttypes.contenttype", "fields": {"model": "article", "name": "article", "app_label": "wiki"}}, {"pk": 51, "model": "contenttypes.contenttype", "fields": {"model": "articleforobject", "name": "Article for object", "app_label": "wiki"}}, {"pk": 54, "model": "contenttypes.contenttype", "fields": {"model": "articleplugin", "name": "article plugin", "app_label": "wiki"}}, {"pk": 52, "model": "contenttypes.contenttype", "fields": {"model": "articlerevision", "name": "article revision", "app_label": "wiki"}}, {"pk": 59, "model": "contenttypes.contenttype", "fields": {"model": "articlesubscription", "name": "article subscription", "app_label": "wiki"}}, {"pk": 20, "model": "contenttypes.contenttype", "fields": {"model": "association", "name": "association", "app_label": "django_openid_auth"}}, {"pk": 78, "model": "contenttypes.contenttype", "fields": {"model": "certificateitem", "name": "certificate item", "app_label": "shoppingcart"}}, {"pk": 40, "model": "contenttypes.contenttype", "fields": {"model": "certificatewhitelist", "name": "certificate whitelist", "app_label": "certificates"}}, {"pk": 4, "model": "contenttypes.contenttype", "fields": {"model": "contenttype", "name": "content type", "app_label": "contenttypes"}}, {"pk": 48, "model": "contenttypes.contenttype", "fields": {"model": "courseauthorization", "name": "course authorization", "app_label": "bulk_email"}}, {"pk": 45, "model": "contenttypes.contenttype", "fields": {"model": "courseemail", "name": "course email", "app_label": "bulk_email"}}, {"pk": 47, "model": "contenttypes.contenttype", "fields": {"model": "courseemailtemplate", "name": "course email template", "app_label": "bulk_email"}}, {"pk": 37, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollment", "name": "course enrollment", "app_label": "student"}}, {"pk": 38, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollmentallowed", "name": "course enrollment allowed", "app_label": "student"}}, {"pk": 71, "model": "contenttypes.contenttype", "fields": {"model": "coursemode", "name": "course mode", "app_label": "course_modes"}}, {"pk": 43, "model": "contenttypes.contenttype", "fields": {"model": "coursesoftware", "name": "course software", "app_label": "licenses"}}, {"pk": 18, "model": "contenttypes.contenttype", "fields": {"model": "courseusergroup", "name": "course user group", "app_label": "course_groups"}}, {"pk": 10, "model": "contenttypes.contenttype", "fields": {"model": "crontabschedule", "name": "crontab", "app_label": "djcelery"}}, {"pk": 49, "model": "contenttypes.contenttype", "fields": {"model": "externalauthmap", "name": "external auth map", "app_label": "external_auth"}}, {"pk": 66, "model": "contenttypes.contenttype", "fields": {"model": "flag", "name": "flag", "app_label": "waffle"}}, {"pk": 41, "model": "contenttypes.contenttype", "fields": {"model": "generatedcertificate", "name": "generated certificate", "app_label": "certificates"}}, {"pk": 2, "model": "contenttypes.contenttype", "fields": {"model": "group", "name": "group", "app_label": "auth"}}, {"pk": 42, "model": "contenttypes.contenttype", "fields": {"model": "instructortask", "name": "instructor task", "app_label": "instructor_task"}}, {"pk": 9, "model": "contenttypes.contenttype", "fields": {"model": "intervalschedule", "name": "interval", "app_label": "djcelery"}}, {"pk": 73, "model": "contenttypes.contenttype", "fields": {"model": "linkedin", "name": "linked in", "app_label": "linkedin"}}, {"pk": 22, "model": "contenttypes.contenttype", "fields": {"model": "logentry", "name": "log entry", "app_label": "admin"}}, {"pk": 15, "model": "contenttypes.contenttype", "fields": {"model": "migrationhistory", "name": "migration history", "app_label": "south"}}, {"pk": 19, "model": "contenttypes.contenttype", "fields": {"model": "nonce", "name": "nonce", "app_label": "django_openid_auth"}}, {"pk": 69, "model": "contenttypes.contenttype", "fields": {"model": "note", "name": "note", "app_label": "notes"}}, {"pk": 63, "model": "contenttypes.contenttype", "fields": {"model": "notification", "name": "notification", "app_label": "django_notify"}}, {"pk": 28, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgrade", "name": "offline computed grade", "app_label": "courseware"}}, {"pk": 29, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgradelog", "name": "offline computed grade log", "app_label": "courseware"}}, {"pk": 46, "model": "contenttypes.contenttype", "fields": {"model": "optout", "name": "optout", "app_label": "bulk_email"}}, {"pk": 74, "model": "contenttypes.contenttype", "fields": {"model": "order", "name": "order", "app_label": "shoppingcart"}}, {"pk": 75, "model": "contenttypes.contenttype", "fields": {"model": "orderitem", "name": "order item", "app_label": "shoppingcart"}}, {"pk": 76, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistration", "name": "paid course registration", "app_label": "shoppingcart"}}, {"pk": 77, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistrationannotation", "name": "paid course registration annotation", "app_label": "shoppingcart"}}, {"pk": 36, "model": "contenttypes.contenttype", "fields": {"model": "pendingemailchange", "name": "pending email change", "app_label": "student"}}, {"pk": 35, "model": "contenttypes.contenttype", "fields": {"model": "pendingnamechange", "name": "pending name change", "app_label": "student"}}, {"pk": 12, "model": "contenttypes.contenttype", "fields": {"model": "periodictask", "name": "periodic task", "app_label": "djcelery"}}, {"pk": 11, "model": "contenttypes.contenttype", "fields": {"model": "periodictasks", "name": "periodic tasks", "app_label": "djcelery"}}, {"pk": 1, "model": "contenttypes.contenttype", "fields": {"model": "permission", "name": "permission", "app_label": "auth"}}, {"pk": 17, "model": "contenttypes.contenttype", "fields": {"model": "psychometricdata", "name": "psychometric data", "app_label": "psychometrics"}}, {"pk": 65, "model": "contenttypes.contenttype", "fields": {"model": "puzzlecomplete", "name": "puzzle complete", "app_label": "foldit"}}, {"pk": 34, "model": "contenttypes.contenttype", "fields": {"model": "registration", "name": "registration", "app_label": "student"}}, {"pk": 55, "model": "contenttypes.contenttype", "fields": {"model": "reusableplugin", "name": "reusable plugin", "app_label": "wiki"}}, {"pk": 57, "model": "contenttypes.contenttype", "fields": {"model": "revisionplugin", "name": "revision plugin", "app_label": "wiki"}}, {"pk": 58, "model": "contenttypes.contenttype", "fields": {"model": "revisionpluginrevision", "name": "revision plugin revision", "app_label": "wiki"}}, {"pk": 68, "model": "contenttypes.contenttype", "fields": {"model": "sample", "name": "sample", "app_label": "waffle"}}, {"pk": 8, "model": "contenttypes.contenttype", "fields": {"model": "tasksetmeta", "name": "saved group result", "app_label": "djcelery"}}, {"pk": 64, "model": "contenttypes.contenttype", "fields": {"model": "score", "name": "score", "app_label": "foldit"}}, {"pk": 16, "model": "contenttypes.contenttype", "fields": {"model": "servercircuit", "name": "server circuit", "app_label": "circuit"}}, {"pk": 5, "model": "contenttypes.contenttype", "fields": {"model": "session", "name": "session", "app_label": "sessions"}}, {"pk": 61, "model": "contenttypes.contenttype", "fields": {"model": "settings", "name": "settings", "app_label": "django_notify"}}, {"pk": 56, "model": "contenttypes.contenttype", "fields": {"model": "simpleplugin", "name": "simple plugin", "app_label": "wiki"}}, {"pk": 6, "model": "contenttypes.contenttype", "fields": {"model": "site", "name": "site", "app_label": "sites"}}, {"pk": 72, "model": "contenttypes.contenttype", "fields": {"model": "softwaresecurephotoverification", "name": "software secure photo verification", "app_label": "verify_student"}}, {"pk": 23, "model": "contenttypes.contenttype", "fields": {"model": "studentmodule", "name": "student module", "app_label": "courseware"}}, {"pk": 24, "model": "contenttypes.contenttype", "fields": {"model": "studentmodulehistory", "name": "student module history", "app_label": "courseware"}}, {"pk": 62, "model": "contenttypes.contenttype", "fields": {"model": "subscription", "name": "subscription", "app_label": "django_notify"}}, {"pk": 67, "model": "contenttypes.contenttype", "fields": {"model": "switch", "name": "switch", "app_label": "waffle"}}, {"pk": 14, "model": "contenttypes.contenttype", "fields": {"model": "taskstate", "name": "task", "app_label": "djcelery"}}, {"pk": 7, "model": "contenttypes.contenttype", "fields": {"model": "taskmeta", "name": "task state", "app_label": "djcelery"}}, {"pk": 39, "model": "contenttypes.contenttype", "fields": {"model": "trackinglog", "name": "tracking log", "app_label": "track"}}, {"pk": 60, "model": "contenttypes.contenttype", "fields": {"model": "notificationtype", "name": "type", "app_label": "django_notify"}}, {"pk": 53, "model": "contenttypes.contenttype", "fields": {"model": "urlpath", "name": "URL path", "app_label": "wiki"}}, {"pk": 3, "model": "contenttypes.contenttype", "fields": {"model": "user", "name": "user", "app_label": "auth"}}, {"pk": 44, "model": "contenttypes.contenttype", "fields": {"model": "userlicense", "name": "user license", "app_label": "licenses"}}, {"pk": 21, "model": "contenttypes.contenttype", "fields": {"model": "useropenid", "name": "user open id", "app_label": "django_openid_auth"}}, {"pk": 70, "model": "contenttypes.contenttype", "fields": {"model": "userpreference", "name": "user preference", "app_label": "user_api"}}, {"pk": 32, "model": "contenttypes.contenttype", "fields": {"model": "userprofile", "name": "user profile", "app_label": "student"}}, {"pk": 31, "model": "contenttypes.contenttype", "fields": {"model": "userstanding", "name": "user standing", "app_label": "student"}}, {"pk": 33, "model": "contenttypes.contenttype", "fields": {"model": "usertestgroup", "name": "user test group", "app_label": "student"}}, {"pk": 13, "model": "contenttypes.contenttype", "fields": {"model": "workerstate", "name": "worker", "app_label": "djcelery"}}, {"pk": 27, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentinfofield", "name": "x module student info field", "app_label": "courseware"}}, {"pk": 26, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentprefsfield", "name": "x module student prefs field", "app_label": "courseware"}}, {"pk": 25, "model": "contenttypes.contenttype", "fields": {"model": "xmoduleuserstatesummaryfield", "name": "x module user state summary field", "app_label": "courseware"}}, {"pk": 1, "model": "sites.site", "fields": {"domain": "example.com", "name": "example.com"}}, {"pk": 1, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:32Z", "app_name": "courseware", "migration": "0001_initial"}}, {"pk": 2, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:32Z", "app_name": "courseware", "migration": "0002_add_indexes"}}, {"pk": 3, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:32Z", "app_name": "courseware", "migration": "0003_done_grade_cache"}}, {"pk": 4, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:32Z", "app_name": "courseware", "migration": "0004_add_field_studentmodule_course_id"}}, {"pk": 5, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0005_auto__add_offlinecomputedgrade__add_unique_offlinecomputedgrade_user_c"}}, {"pk": 6, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0006_create_student_module_history"}}, {"pk": 7, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0007_allow_null_version_in_history"}}, {"pk": 8, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0008_add_xmodule_storage"}}, {"pk": 9, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0009_add_field_default"}}, {"pk": 10, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "courseware", "migration": "0010_rename_xblock_field_content_to_user_state_summary"}}, {"pk": 11, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "student", "migration": "0001_initial"}}, {"pk": 12, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "student", "migration": "0002_text_to_varchar_and_indexes"}}, {"pk": 13, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:33Z", "app_name": "student", "migration": "0003_auto__add_usertestgroup"}}, {"pk": 14, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0004_add_email_index"}}, {"pk": 15, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0005_name_change"}}, {"pk": 16, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0006_expand_meta_field"}}, {"pk": 17, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0007_convert_to_utf8"}}, {"pk": 18, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0008__auto__add_courseregistration"}}, {"pk": 19, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0009_auto__del_courseregistration__add_courseenrollment"}}, {"pk": 20, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0010_auto__chg_field_courseenrollment_course_id"}}, {"pk": 21, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0011_auto__chg_field_courseenrollment_user__del_unique_courseenrollment_use"}}, {"pk": 22, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0012_auto__add_field_userprofile_gender__add_field_userprofile_date_of_birt"}}, {"pk": 23, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0013_auto__chg_field_userprofile_meta"}}, {"pk": 24, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0014_auto__del_courseenrollment"}}, {"pk": 25, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0015_auto__add_courseenrollment__add_unique_courseenrollment_user_course_id"}}, {"pk": 26, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0016_auto__add_field_courseenrollment_date__chg_field_userprofile_country"}}, {"pk": 27, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0017_rename_date_to_created"}}, {"pk": 28, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0018_auto"}}, {"pk": 29, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0019_create_approved_demographic_fields_fall_2012"}}, {"pk": 30, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:34Z", "app_name": "student", "migration": "0020_add_test_center_user"}}, {"pk": 31, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0021_remove_askbot"}}, {"pk": 32, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_"}}, {"pk": 33, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0023_add_test_center_registration"}}, {"pk": 34, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0024_add_allow_certificate"}}, {"pk": 35, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0025_auto__add_field_courseenrollmentallowed_auto_enroll"}}, {"pk": 36, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0026_auto__remove_index_student_testcenterregistration_accommodation_request"}}, {"pk": 37, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0027_add_active_flag_and_mode_to_courseware_enrollment"}}, {"pk": 38, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0028_auto__add_userstanding"}}, {"pk": 39, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0029_add_lookup_table_between_user_and_anonymous_student_id"}}, {"pk": 40, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0029_remove_pearson"}}, {"pk": 41, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0030_auto__chg_field_anonymoususerid_anonymous_user_id"}}, {"pk": 42, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "student", "migration": "0031_drop_student_anonymoususerid_temp_archive"}}, {"pk": 43, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:35Z", "app_name": "track", "migration": "0001_initial"}}, {"pk": 44, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "track", "migration": "0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch"}}, {"pk": 45, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0001_added_generatedcertificates"}}, {"pk": 46, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0002_auto__add_field_generatedcertificate_download_url"}}, {"pk": 47, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0003_auto__add_field_generatedcertificate_enabled"}}, {"pk": 48, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0004_auto__add_field_generatedcertificate_graded_certificate_id__add_field_"}}, {"pk": 49, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0005_auto__add_field_generatedcertificate_name"}}, {"pk": 50, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0006_auto__chg_field_generatedcertificate_certificate_id"}}, {"pk": 51, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0007_auto__add_revokedcertificate"}}, {"pk": 52, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0008_auto__del_revokedcertificate__del_field_generatedcertificate_name__add"}}, {"pk": 53, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0009_auto__del_field_generatedcertificate_graded_download_url__del_field_ge"}}, {"pk": 54, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0010_auto__del_field_generatedcertificate_enabled__add_field_generatedcerti"}}, {"pk": 55, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0011_auto__del_field_generatedcertificate_certificate_id__add_field_generat"}}, {"pk": 56, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0012_auto__add_field_generatedcertificate_name__add_field_generatedcertific"}}, {"pk": 57, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0013_auto__add_field_generatedcertificate_error_reason"}}, {"pk": 58, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0014_adding_whitelist"}}, {"pk": 59, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:36Z", "app_name": "certificates", "migration": "0015_adding_mode_for_verified_certs"}}, {"pk": 60, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "instructor_task", "migration": "0001_initial"}}, {"pk": 61, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "instructor_task", "migration": "0002_add_subtask_field"}}, {"pk": 62, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "licenses", "migration": "0001_initial"}}, {"pk": 63, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0001_initial"}}, {"pk": 64, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0002_change_field_names"}}, {"pk": 65, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0003_add_optout_user"}}, {"pk": 66, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0004_migrate_optout_user"}}, {"pk": 67, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0005_remove_optout_email"}}, {"pk": 68, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0006_add_course_email_template"}}, {"pk": 69, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0007_load_course_email_template"}}, {"pk": 70, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:37Z", "app_name": "bulk_email", "migration": "0008_add_course_authorizations"}}, {"pk": 71, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "external_auth", "migration": "0001_initial"}}, {"pk": 72, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "wiki", "migration": "0001_initial"}}, {"pk": 73, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "wiki", "migration": "0002_auto__add_field_articleplugin_created"}}, {"pk": 74, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "wiki", "migration": "0003_auto__add_field_urlpath_article"}}, {"pk": 75, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "wiki", "migration": "0004_populate_urlpath__article"}}, {"pk": 76, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:38Z", "app_name": "wiki", "migration": "0005_auto__chg_field_urlpath_article"}}, {"pk": 77, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0006_auto__add_attachmentrevision__add_image__add_attachment"}}, {"pk": 78, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0007_auto__add_articlesubscription"}}, {"pk": 79, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0008_auto__add_simpleplugin__add_revisionpluginrevision__add_imagerevision_"}}, {"pk": 80, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0009_auto__add_field_imagerevision_width__add_field_imagerevision_height"}}, {"pk": 81, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0010_auto__chg_field_imagerevision_image"}}, {"pk": 82, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "wiki", "migration": "0011_auto__chg_field_imagerevision_width__chg_field_imagerevision_height"}}, {"pk": 83, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "django_notify", "migration": "0001_initial"}}, {"pk": 84, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:39Z", "app_name": "notifications", "migration": "0001_initial"}}, {"pk": 85, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "foldit", "migration": "0001_initial"}}, {"pk": 86, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0001_initial"}}, {"pk": 87, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0002_auto__add_sample"}}, {"pk": 88, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0003_auto__add_field_flag_note__add_field_switch_note__add_field_sample_not"}}, {"pk": 89, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0004_auto__add_field_flag_testing"}}, {"pk": 90, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0005_auto__add_field_flag_created__add_field_flag_modified"}}, {"pk": 91, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0006_auto__add_field_switch_created__add_field_switch_modified__add_field_s"}}, {"pk": 92, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0007_auto__chg_field_flag_created__chg_field_flag_modified__chg_field_switc"}}, {"pk": 93, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:40Z", "app_name": "waffle", "migration": "0008_auto__add_field_flag_languages"}}, {"pk": 94, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:41Z", "app_name": "django_comment_client", "migration": "0001_initial"}}, {"pk": 95, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:41Z", "app_name": "django_comment_common", "migration": "0001_initial"}}, {"pk": 96, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:41Z", "app_name": "notes", "migration": "0001_initial"}}, {"pk": 97, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:41Z", "app_name": "user_api", "migration": "0001_initial"}}, {"pk": 98, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0001_initial"}}, {"pk": 99, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0002_auto__add_field_coursemode_currency"}}, {"pk": 100, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0003_auto__add_unique_coursemode_course_id_currency_mode_slug"}}, {"pk": 101, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0004_auto__add_field_coursemode_expiration_date"}}, {"pk": 102, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0005_auto__add_field_coursemode_expiration_datetime"}}, {"pk": 103, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "course_modes", "migration": "0006_expiration_date_to_datetime"}}, {"pk": 104, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "verify_student", "migration": "0001_initial"}}, {"pk": 105, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "linkedin", "migration": "0001_initial"}}, {"pk": 106, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "django_extensions", "migration": "0001_empty"}}, {"pk": 107, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:42Z", "app_name": "shoppingcart", "migration": "0001_initial"}}, {"pk": 108, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0002_auto__add_field_paidcourseregistration_mode"}}, {"pk": 109, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0003_auto__del_field_orderitem_line_cost"}}, {"pk": 110, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0004_auto__add_field_orderitem_fulfilled_time"}}, {"pk": 111, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report"}}, {"pk": 112, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques"}}, {"pk": 113, "model": "south.migrationhistory", "fields": {"applied": "2014-01-25T15:57:43Z", "app_name": "shoppingcart", "migration": "0007_auto__add_field_orderitem_service_fee"}}, {"pk": 1, "model": "bulk_email.courseemailtemplate", "fields": {"plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX:\r\nFacebook (http://facebook.com/edxonline)\r\nTwitter (http://twitter.com/edxonline)\r\nGoogle+ (https://plus.google.com/108235383044095082735)\r\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\nThis email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", "html_template": " Update from {course_title}
Connect with edX:
{course_title}
{{message_body}}
Copyright \u00a9 2013 edX, All rights reserved.
Our mailing address is: edX 11 Cambridge Center, Suite 101 Cambridge, MA, USA 02142
This email was automatically sent from {platform_name}. You are receiving this email at address {email} because you are enrolled in {course_title}. To stop receiving email like this, update your course email settings here.