Commit 92c2fdbc by Will Daly Committed by Awais

Add new models to embargo to support country access

Add Django admin UI for configuring country access

Migrate existing embargo rules into the new tables.

ECOM-996: updated the middleware to use new models and access rules

ECOM-996: added the flag to support old and new formats

ECOM-996: added the api layer for country access settings

ECOM-996: added the api layer for country access settings

ECOM-996 implementing the white and blacklist checks.

ECOM-996 minor re-factoring in api.

ECOM-996 minor re-factoring in api.

ECOM-1025 refactoring the code according to PR feedback.

ECOM-1025 refactoring the code according to PR feedback.

ECOM-1025 deleting cache in model save and delete methods

ECOM-1025 adding basic api test cases file.

ECOM-1025 refactoring the code according to PR feedback.

ECOM-1025 refactoring the code according to PR feedback.

ECOM-1025 refactoring the code according to PR feedback. adding the test cases.

ECOM-1025 removing extra line

ECOM-1025 removing un-used function.

ECOM-1025 removing un-used function.

ECOM-1025 re-factor the code.

ECOM-1025 re-name the test file to test_middleware_access_rules.py. we already had old test_middleware.py

ECOM-1025 adding test cases for newly added models.

ECOM-1025 adding test cases and resolve conflicts.

ECOM-1025 fixing the quality and pep-8 issues.

ECOM-1025 re-factoring the code according to the PR feedback.

ECOM-1025 re-name the variable name.

ECOM-1025 removing the _check_ip_lists and its test cases. also added few missing scenarios test cases.

ECOM-1025 removing un-used line.
parent e43f1a8b
"""
The Python API layer of the country access settings. Essentially the middle tier of the project, responsible for all
business logic that is not directly tied to the data itself.
This API is exposed via the middleware(emabargo/middileware.py) layer but may be used directly in-process.
"""
import logging
import pygeoip
from django.core.cache import cache
from django.conf import settings
from embargo.models import CountryAccessRule, RestrictedCourse
log = logging.getLogger(__name__)
def get_user_country_from_profile(user):
"""
Check whether the user is embargoed based on the country code in the user's profile.
Args:
user (User): The user attempting to access courseware.
Returns:
user country from profile.
"""
cache_key = u'user.{user_id}.profile.country'.format(user_id=user.id)
profile_country = cache.get(cache_key)
if profile_country is None:
profile = getattr(user, 'profile', None)
if profile is not None and profile.country.code is not None:
profile_country = profile.country.code.upper()
else:
profile_country = ""
cache.set(cache_key, profile_country)
return profile_country
def _country_code_from_ip(ip_addr):
"""
Return the country code associated with an IP address.
Handles both IPv4 and IPv6 addresses.
Args:
ip_addr (str): The IP address to look up.
Returns:
str: A 2-letter country code.
"""
if ip_addr.find(':') >= 0:
return pygeoip.GeoIP(settings.GEOIPV6_PATH).country_code_by_addr(ip_addr)
else:
return pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
def check_course_access(user, ip_address, course_key):
"""
Check is the user with this ip_address has access to the given course
Params:
user (User): Currently logged in user object
ip_address (str): The ip_address of user
course_key (CourseLocator): CourseLocator object the user is trying to access
Returns:
The return will be True if the user has access on the course.
if any constraints fails it will return the False
"""
course_is_restricted = RestrictedCourse.is_restricted_course(course_key)
# If they're trying to access a course that cares about embargoes
# If course is not restricted then return immediately return True
# no need for further checking
if not course_is_restricted:
return True
# Retrieve the country code from the IP address
# and check it against the allowed countries list for a course
user_country_from_ip = _country_code_from_ip(ip_address)
# if user country has access to course return True
if not CountryAccessRule.check_country_access(course_key, user_country_from_ip):
return False
# Retrieve the country code from the user profile.
user_country_from_profile = get_user_country_from_profile(user)
# if profile country has access return True
if not CountryAccessRule.check_country_access(course_key, user_country_from_profile):
return False
return True
......@@ -45,6 +45,7 @@ from util.request import course_id_from_url
from student.models import unique_id_for_user
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.api import check_course_access
log = logging.getLogger(__name__)
......@@ -73,11 +74,18 @@ class EmbargoMiddleware(object):
# If embargoing is turned off, make this middleware do nothing
if not settings.FEATURES.get('EMBARGO', False) and not self.site_enabled:
raise MiddlewareNotUsed()
self.enable_country_access = settings.FEATURES.get('ENABLE_COUNTRY_ACCESS', False)
def process_request(self, request):
"""
Processes embargo requests.
"""
if self.enable_country_access:
if self.country_access_rules(request):
return None
else:
return self._embargo_redirect_response
url = request.path
course_id = course_id_from_url(url)
course_is_embargoed = EmbargoedCourse.is_embargoed(course_id)
......@@ -297,3 +305,20 @@ class EmbargoMiddleware(object):
return True
return _inner
def country_access_rules(self, request):
"""
check the country access rules for a given course.
if course id is invalid return True
Args:
request
Return:
boolean: True if the user has access else false.
"""
url = request.path
course_id = course_id_from_url(url)
if course_id is None:
return True
return check_course_access(request.user, get_ip(request), course_id)
......@@ -15,8 +15,10 @@ import ipaddr
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
from django.core.cache import cache
from django_countries.fields import CountryField
from django_countries import countries
from config_models.models import ConfigurationModel
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
......@@ -24,6 +26,10 @@ from xmodule_django.models import CourseKeyField, NoneToEmptyManager
from embargo.messages import ENROLL_MESSAGES, COURSEWARE_MESSAGES
WHITE_LIST = 'whitelist'
BLACK_LIST = 'blacklist'
class EmbargoedCourse(models.Model):
"""
Enable course embargo on a course-by-course basis.
......@@ -123,9 +129,50 @@ class RestrictedCourse(models.Model):
help_text=ugettext_lazy(u"The message to show when a user is blocked from accessing a course.")
)
@classmethod
def cache_key_name(cls):
"""Return the name of the key to use to cache the current restricted course list"""
return 'embargo/RestrictedCourse/courses'
@classmethod
def is_restricted_course(cls, course_id):
"""
Check if the course is in restricted list
Args:
course_id (str): course_id to look for
Returns:
Boolean
True if course is in restricted course list.
"""
return unicode(course_id) in cls._get_restricted_courses_from_cache()
@classmethod
def _get_restricted_courses_from_cache(cls):
"""
Cache all restricted courses and returns the list of course_keys that are restricted
"""
restricted_courses = cache.get(cls.cache_key_name())
if not restricted_courses:
restricted_courses = list(RestrictedCourse.objects.values_list('course_key', flat=True))
cache.set(cls.cache_key_name(), restricted_courses)
return restricted_courses
def __unicode__(self):
return unicode(self.course_key)
def save(self, *args, **kwargs):
"""
Clear the cached value when saving a RestrictedCourse entry
"""
super(RestrictedCourse, self).save(*args, **kwargs)
cache.delete(self.cache_key_name())
def delete(self, using=None):
super(RestrictedCourse, self).delete()
cache.delete(self.cache_key_name())
class Country(models.Model):
"""Representation of a country.
......@@ -147,7 +194,7 @@ class Country(models.Model):
)
class Meta:
# Default ordering is ascending by country code
"""Default ordering is ascending by country code """
ordering = ['country']
......@@ -170,14 +217,14 @@ class CountryAccessRule(models.Model):
"""
RULE_TYPE_CHOICES = (
('whitelist', 'Whitelist (allow only these countries)'),
('blacklist', 'Blacklist (block these countries)'),
(WHITE_LIST, 'Whitelist (allow only these countries)'),
(BLACK_LIST, 'Blacklist (block these countries)'),
)
rule_type = models.CharField(
max_length=255,
choices=RULE_TYPE_CHOICES,
default='blacklist',
default=BLACK_LIST,
help_text=ugettext_lazy(
u"Whether to include or exclude the given course. "
u"If whitelist countries are specified, then ONLY users from whitelisted countries "
......@@ -196,19 +243,102 @@ class CountryAccessRule(models.Model):
help_text=ugettext_lazy(u"The country to which this rule applies.")
)
@classmethod
def cache_key_for_consolidated_countries(cls, course_id):
"""
Args:
course_id (str): course_id to look for
Returns:
Consolidated list of accessible countries for given course
"""
return "{}/allowed/countries".format(course_id)
@classmethod
def check_country_access(cls, course_id, country):
"""
Check if the country is either in whitelist or blacklist of countries for the course_id
Args:
course_id (str): course_id to look for
country (str): A 2 characters code of country
Returns:
Boolean
True if country found in allowed country
otherwise check given country exists in list
"""
allowed_countries = cache.get(cls.cache_key_for_consolidated_countries(course_id))
if not allowed_countries:
allowed_countries = cls._get_country_access_list(course_id)
cache.set(cls.cache_key_for_consolidated_countries(course_id), allowed_countries)
return country == '' or country in allowed_countries
@classmethod
def _get_country_access_list(cls, course_id):
"""
if a course is blacklist for two countries then course can be accessible from
any where except these two countries.
if a course is whitelist for two countries then course can be accessible from
these countries only.
Args:
course_id (str): course_id to look for
Returns:
List
Consolidated list of accessible countries for given course
"""
whitelist_countries = set()
blacklist_countries = set()
# Retrieve all rules in one database query, performing the "join" with the Country table
rules_for_course = CountryAccessRule.objects.select_related('country').filter(
restricted_course__course_key=course_id
)
# Filter the rules into a whitelist and blacklist in one pass
for rule in rules_for_course:
if rule.rule_type == 'whitelist':
whitelist_countries.add(rule.country.country.code)
elif rule.rule_type == 'blacklist':
blacklist_countries.add(rule.country.country.code)
# If there are no whitelist countries, default to all countries
if not whitelist_countries:
whitelist_countries = set(code[0] for code in list(countries))
# Consolidate the rules into a single list of countries
# that have access to the course.
return list(whitelist_countries - blacklist_countries)
def __unicode__(self):
if self.rule_type == 'whitelist':
if self.rule_type == WHITE_LIST:
return _(u"Whitelist {country} for {course}").format(
course=unicode(self.restricted_course.course_key),
country=unicode(self.country),
)
elif self.rule_type == 'blacklist':
elif self.rule_type == BLACK_LIST:
return _(u"Blacklist {country} for {course}").format(
course=unicode(self.restricted_course.course_key),
country=unicode(self.country),
)
def save(self, *args, **kwargs):
"""
Clear the cached value when saving a entry
"""
super(CountryAccessRule, self).save(*args, **kwargs)
cache.delete(self.cache_key_for_consolidated_countries(unicode(self.restricted_course.course_key)))
def delete(self, using=None):
"""
clear the cached value when saving a entry
"""
super(CountryAccessRule, self).delete()
cache.delete(self.cache_key_for_consolidated_countries(unicode(self.restricted_course.course_key)))
class Meta:
"""a course can be added with either black or white list. """
unique_together = (
# This restriction ensures that a country is on
# either the whitelist or the blacklist, but
......
"""Test of models for embargo middleware app"""
from django.test import TestCase
from django.db.utils import IntegrityError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.models import (
EmbargoedCourse, EmbargoedState, IPFilter, RestrictedCourse,
Country, CountryAccessRule, WHITE_LIST, BLACK_LIST
)
class EmbargoModelsTest(TestCase):
......@@ -95,3 +98,137 @@ class EmbargoModelsTest(TestCase):
self.assertTrue('1.1.0.1' in cblacklist)
self.assertTrue('1.1.1.0' in cblacklist)
self.assertFalse('1.2.0.0' in cblacklist)
class RestrictedCourseTest(TestCase):
"""Test unicode values tests and cache functionality"""
def test_unicode_values(self):
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
restricted_course = RestrictedCourse.objects.create(course_key=course_id)
self.assertEquals(
restricted_course.__unicode__(),
"abc/123/doremi"
)
def test_restricted_course_cache_with_save_delete(self):
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
RestrictedCourse.objects.create(course_key=course_id)
# Warm the cache
with self.assertNumQueries(1):
RestrictedCourse.is_restricted_course(course_id)
# it should come from cache
with self.assertNumQueries(0):
RestrictedCourse.is_restricted_course(course_id)
# add new the course so the cache must get delete and again hit the db
new_course_id = SlashSeparatedCourseKey('def', '123', 'doremi')
RestrictedCourse.objects.create(course_key=new_course_id)
with self.assertNumQueries(1):
RestrictedCourse.is_restricted_course(new_course_id)
# it should come from cache
with self.assertNumQueries(0):
RestrictedCourse.is_restricted_course(new_course_id)
# deleting an object will delete cache also.and hit db on
# get the is_restricted course
abc = RestrictedCourse.objects.get(course_key=new_course_id)
abc.delete()
with self.assertNumQueries(1):
RestrictedCourse.is_restricted_course(new_course_id)
# it should come from cache
with self.assertNumQueries(0):
RestrictedCourse.is_restricted_course(new_course_id)
class CountryTest(TestCase):
"""Test unicode values test"""
def test_unicode_values(self):
country = Country.objects.create(country='NZ')
self.assertEquals(
country.__unicode__(),
"New Zealand (NZ)"
)
class CountryAccessRuleTest(TestCase):
"""Test unicode values tests and unique-together contraint"""
def test_unicode_values(self):
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
country = Country.objects.create(country='NZ')
restricted_course1 = RestrictedCourse.objects.create(course_key=course_id)
access_rule = CountryAccessRule.objects.create(
restricted_course=restricted_course1,
rule_type=WHITE_LIST,
country=country
)
self.assertEquals(
access_rule.__unicode__(),
"Whitelist New Zealand (NZ) for abc/123/doremi"
)
course_id = SlashSeparatedCourseKey('def', '123', 'doremi')
restricted_course1 = RestrictedCourse.objects.create(course_key=course_id)
access_rule = CountryAccessRule.objects.create(
restricted_course=restricted_course1,
rule_type=BLACK_LIST,
country=country
)
self.assertEquals(
access_rule.__unicode__(),
"Blacklist New Zealand (NZ) for def/123/doremi"
)
def test_unique_together_constraint(self):
"""
Course with specific country can be added either as whitelist or blacklist
trying to add with both types will raise error
"""
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
country = Country.objects.create(country='NZ')
restricted_course1 = RestrictedCourse.objects.create(course_key=course_id)
CountryAccessRule.objects.create(
restricted_course=restricted_course1,
rule_type=WHITE_LIST,
country=country
)
with self.assertRaises(IntegrityError):
CountryAccessRule.objects.create(
restricted_course=restricted_course1,
rule_type=BLACK_LIST,
country=country
)
def test_country_access_list_cache_with_save_delete(self):
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
country = Country.objects.create(country='NZ')
restricted_course1 = RestrictedCourse.objects.create(course_key=course_id)
course = CountryAccessRule.objects.create(
restricted_course=restricted_course1,
rule_type=WHITE_LIST,
country=country
)
# Warm the cache
with self.assertNumQueries(1):
CountryAccessRule.check_country_access(course_id, 'NZ')
with self.assertNumQueries(0):
CountryAccessRule.check_country_access(course_id, 'NZ')
# deleting an object will delete cache also.and hit db on
# get the country access lists for course
course.delete()
with self.assertNumQueries(1):
CountryAccessRule.check_country_access(course_id, 'NZ')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment