From fe85a1eec1bbd11d657b950fa39bf0d3195e2769 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Sat, 22 Feb 2014 18:04:12 -0500 Subject: [PATCH] Django-admin for Embargo feature Allows specification of countries to embargo, what course(s) should apply embargo restrictions, and whitelist/blacklist capability for specific individual IP addresses. --- common/djangoapps/embargo/admin.py | 61 +++++++++ .../djangoapps/embargo/fixtures/__init__.py | 0 .../embargo/fixtures/country_codes.py | 25 ++++ common/djangoapps/embargo/forms.py | 127 ++++++++++++++++++ common/djangoapps/embargo/middleware.py | 17 ++- .../embargo/migrations/0001_initial.py | 56 ++++++-- .../djangoapps/embargo/migrations/__init__.py | 0 common/djangoapps/embargo/models.py | 87 +++++++++--- common/djangoapps/embargo/tests/tests.py | 14 +- common/djangoapps/student/views.py | 2 +- 10 files changed, 349 insertions(+), 40 deletions(-) create mode 100644 common/djangoapps/embargo/admin.py create mode 100644 common/djangoapps/embargo/fixtures/__init__.py create mode 100644 common/djangoapps/embargo/fixtures/country_codes.py create mode 100644 common/djangoapps/embargo/forms.py create mode 100644 common/djangoapps/embargo/migrations/__init__.py diff --git a/common/djangoapps/embargo/admin.py b/common/djangoapps/embargo/admin.py new file mode 100644 index 0000000000..8f2f85b484 --- /dev/null +++ b/common/djangoapps/embargo/admin.py @@ -0,0 +1,61 @@ +""" +Django admin page for embargo models +""" +from django.contrib import admin + +from config_models.admin import ConfigurationModelAdmin +from embargo.models import EmbargoedCourse, EmbargoedState, IPException +from embargo.forms import EmbargoedCourseForm, EmbargoedStateForm, IPExceptionForm + + +class EmbargoedCourseAdmin(admin.ModelAdmin): + """Admin for embargoed course ids""" + form = EmbargoedCourseForm + fieldsets = ( + (None, { + 'fields': ('course_id', 'embargoed'), + 'description': ''' +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': ''' +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 IPExceptionAdmin(ConfigurationModelAdmin): + """Admin for blacklisting/whitelisting specific IP addresses""" + form = IPExceptionForm + fieldsets = ( + (None, { + 'fields': ('whitelist', 'blacklist'), + 'description': ''' +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(IPException, IPExceptionAdmin) 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..43f8ea9162 --- /dev/null +++ b/common/djangoapps/embargo/forms.py @@ -0,0 +1,127 @@ +""" +Defines forms for providing validation of embargo admin details. +""" + +from django import forms + +from embargo.models import EmbargoedCourse, EmbargoedState, IPException +from embargo.fixtures.country_codes import COUNTRY_CODES + +import socket + +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError + + +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: + # Try to get the course descriptor, if we can do that, + # it's a real course. + course_loc = CourseDescriptor.id_to_location(course_id) + modulestore().get_instance(course_id, course_loc, depth=1) + except (KeyError, ItemNotFoundError): + 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) + except (ValueError, InvalidLocationError): + msg = 'INVALID LOCATION' + 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""" + if len(code) == 2 and code in COUNTRY_CODES: + return True + return False + + def clean_embargoed_countries(self): + """Validate the country list""" + embargoed_countries = self.cleaned_data["embargoed_countries"] + 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 IPExceptionForm(forms.ModelForm): # pylint: disable=incomplete-protocol + """Form validating entry of IP addresses""" + + class Meta: # pylint: disable=missing-docstring + model = IPException + + 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 index c521de3c07..2dfe4c6ba6 100644 --- a/common/djangoapps/embargo/middleware.py +++ b/common/djangoapps/embargo/middleware.py @@ -5,7 +5,7 @@ Middleware for embargoing courses. from django.shortcuts import redirect from util.request import course_id_from_url -from embargo.models import EmbargoConfig +from embargo.models import EmbargoedCourse, EmbargoedState, IPException from ipware.ip import get_ip import pygeoip from django.conf import settings @@ -27,11 +27,16 @@ class EmbargoMiddleware(object): course_id = course_id_from_url(url) # If they're trying to access a course that cares about embargoes - if course_id in EmbargoConfig.current().embargoed_courses_list: + if EmbargoedCourse.is_embargoed(course_id): # If we're having performance issues, add caching here - ip = get_ip(request) - country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip) - is_embargoed = (country_code_from_ip in EmbargoConfig.current().embargoed_countries_list) - if is_embargoed: + ip_addr = get_ip(request) + # if blacklisted, immediately fail + if ip_addr in IPException.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 IPException.current().whitelist_ips: return redirect('embargo') diff --git a/common/djangoapps/embargo/migrations/0001_initial.py b/common/djangoapps/embargo/migrations/0001_initial.py index f5289c559d..cd4a26197d 100644 --- a/common/djangoapps/embargo/migrations/0001_initial.py +++ b/common/djangoapps/embargo/migrations/0001_initial.py @@ -8,21 +8,45 @@ from django.db import models class Migration(SchemaMigration): def forwards(self, orm): - # Adding model 'EmbargoConfig' - db.create_table('embargo_embargoconfig', ( + # 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)), - ('embargoed_courses', self.gf('django.db.models.fields.TextField')(blank=True)), )) - db.send_create_signal('embargo', ['EmbargoConfig']) + db.send_create_signal('embargo', ['EmbargoedState']) + + # Adding model 'IPException' + db.create_table('embargo_ipexception', ( + ('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', ['IPException']) def backwards(self, orm): - # Deleting model 'EmbargoConfig' - db.delete_table('embargo_embargoconfig') + # Deleting model 'EmbargoedCourse' + db.delete_table('embargo_embargoedcourse') + + # Deleting model 'EmbargoedState' + db.delete_table('embargo_embargoedstate') + + # Deleting model 'IPException' + db.delete_table('embargo_ipexception') models = { @@ -62,14 +86,28 @@ class Migration(SchemaMigration): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - 'embargo.embargoconfig': { - 'Meta': {'object_name': 'EmbargoConfig'}, + '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'}), - 'embargoed_courses': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'embargo.ipexception': { + 'Meta': {'object_name': 'IPException'}, + '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'}) } } 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 index 3bbe5e4fb1..a1b4aa4276 100644 --- a/common/djangoapps/embargo/models.py +++ b/common/djangoapps/embargo/models.py @@ -1,39 +1,92 @@ """ -Models for embargoing countries +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 EmbargoConfig(ConfigurationModel): +class EmbargoedCourse(models.Model): """ - Configuration for the embargo feature + 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" ) - embargoed_courses = models.TextField( - blank=True, - help_text="A comma-separated list of course IDs that we are enforcing the embargo for" - ) - @property def embargoed_countries_list(self): """ - Returns list of embargoed countries + Return a list of upper case country codes """ - if not self.embargoed_countries.strip(): - return [] - return [country.strip() for country in self.embargoed_countries.split(',')] + return [country.strip().upper() for country in self.embargoed_countries.split(',')] # pylint: disable=no-member + + +class IPException(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 embargoed_courses_list(self): + def whitelist_ips(self): """ - Returns list of embargoed courses + Return a list of valid IP addresses to whitelist """ - if not self.embargoed_courses.strip(): - return [] - return [course.strip() for course in self.embargoed_courses.split(',')] + 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 + """ + return [addr.strip() for addr in self.blacklist.split(',')] # pylint: disable=no-member diff --git a/common/djangoapps/embargo/tests/tests.py b/common/djangoapps/embargo/tests/tests.py index a76d59ad94..e2e9fd7149 100644 --- a/common/djangoapps/embargo/tests/tests.py +++ b/common/djangoapps/embargo/tests/tests.py @@ -6,7 +6,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from django.test import TestCase from django.test.utils import override_settings from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE -from embargo.models import EmbargoConfig +from embargo.models import EmbargoedCourse, EmbargoedState from django.test import Client from student.models import CourseEnrollment from student.tests.factories import UserFactory @@ -29,18 +29,18 @@ class EmbargoMiddlewareTests(TestCase): self.regular_course.save() self.embargoed_page = '/courses/' + self.embargo_course.id + '/info' self.regular_page = '/courses/' + self.regular_course.id + '/info' - EmbargoConfig( - embargoed_countries="CU, IR, SY,SD", - embargoed_courses=self.embargo_course.id, + EmbargoedCourse(course_id=self.embargo_course.id, embargoed=True).save() + EmbargoedState( + embargoed_countries="cu, ir, Sy, SD", changed_by=self.user, enabled=True ).save() - + # TODO need to set up & test whitelist/blacklist IPs (IPException model) CourseEnrollment.enroll(self.user, self.regular_course.id) CourseEnrollment.enroll(self.user, self.embargo_course.id) def test_countries(self): - def mock_country_code_by_addr(ip): + def mock_country_code_by_addr(ip_addr): """ Gives us a fake set of IPs """ @@ -50,7 +50,7 @@ class EmbargoMiddlewareTests(TestCase): '3.0.0.0': 'SY', '4.0.0.0': 'SD', } - return ip_dict.get(ip, 'US') + return ip_dict.get(ip_addr, 'US') with mock.patch.object(pygeoip.GeoIP, 'country_code_by_addr') as mocked_method: mocked_method.side_effect = mock_country_code_by_addr diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ebc6597a35..5b3d3c3aaa 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -140,7 +140,7 @@ def _get_date_for_press(publish_date): return date -def embargo(request): +def embargo(_request): """ Render the embargo page.