From 86e954f6424be9dc024b0f819e73587aa3a79afe Mon Sep 17 00:00:00 2001 From: wajeeha-khalid Date: Tue, 16 Feb 2016 13:55:03 +0500 Subject: [PATCH] version-based mobile app upgrade --- lms/djangoapps/mobile_api/admin.py | 17 +- .../mobile_api/course_info/tests.py | 1 - lms/djangoapps/mobile_api/middleware.py | 133 +++++++++++++++ .../migrations/0002_auto_20160406_0904.py | 36 ++++ lms/djangoapps/mobile_api/mobile_platform.py | 69 ++++++++ lms/djangoapps/mobile_api/models.py | 59 ++++++- lms/djangoapps/mobile_api/tests.py | 59 ------- lms/djangoapps/mobile_api/tests/__init__.py | 0 .../mobile_api/tests/test_decorator.py | 28 ++++ .../mobile_api/tests/test_middleware.py | 156 ++++++++++++++++++ .../mobile_api/{ => tests}/test_milestones.py | 0 .../mobile_api/tests/test_mobile_platform.py | 46 ++++++ lms/djangoapps/mobile_api/tests/test_model.py | 115 +++++++++++++ lms/djangoapps/mobile_api/testutils.py | 13 +- lms/djangoapps/mobile_api/users/tests.py | 16 +- lms/djangoapps/mobile_api/utils.py | 5 + .../mobile_api/video_outlines/tests.py | 12 +- lms/envs/aws.py | 3 + lms/envs/common.py | 5 +- openedx/core/lib/mobile_utils.py | 2 +- 20 files changed, 687 insertions(+), 88 deletions(-) create mode 100644 lms/djangoapps/mobile_api/middleware.py create mode 100644 lms/djangoapps/mobile_api/migrations/0002_auto_20160406_0904.py create mode 100644 lms/djangoapps/mobile_api/mobile_platform.py delete mode 100644 lms/djangoapps/mobile_api/tests.py create mode 100644 lms/djangoapps/mobile_api/tests/__init__.py create mode 100644 lms/djangoapps/mobile_api/tests/test_decorator.py create mode 100644 lms/djangoapps/mobile_api/tests/test_middleware.py rename lms/djangoapps/mobile_api/{ => tests}/test_milestones.py (100%) create mode 100644 lms/djangoapps/mobile_api/tests/test_mobile_platform.py create mode 100644 lms/djangoapps/mobile_api/tests/test_model.py diff --git a/lms/djangoapps/mobile_api/admin.py b/lms/djangoapps/mobile_api/admin.py index 4a5305ad1e..b73bd523a3 100644 --- a/lms/djangoapps/mobile_api/admin.py +++ b/lms/djangoapps/mobile_api/admin.py @@ -4,6 +4,21 @@ Django admin dashboard configuration for LMS XBlock infrastructure. from django.contrib import admin from config_models.admin import ConfigurationModelAdmin -from mobile_api.models import MobileApiConfig +from mobile_api.models import MobileApiConfig, AppVersionConfig admin.site.register(MobileApiConfig, ConfigurationModelAdmin) + + +class AppVersionConfigAdmin(admin.ModelAdmin): + """ Admin class for AppVersionConfig model """ + fields = ('platform', 'version', 'expire_at', 'enabled') + list_filter = ['platform'] + + class Meta(object): + ordering = ['-major_version', '-minor_version', '-patch_version'] + + def get_list_display(self, __): + """ defines fields to display in list view """ + return ['platform', 'version', 'expire_at', 'enabled', 'created_at', 'updated_at'] + +admin.site.register(AppVersionConfig, AppVersionConfigAdmin) diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 87120064b8..f95643e15e 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -9,7 +9,6 @@ from xmodule.html_module import CourseInfoModule from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.xml_importer import import_course_from_xml - from milestones.tests.utils import MilestonesTestCaseMixin from ..testutils import ( diff --git a/lms/djangoapps/mobile_api/middleware.py b/lms/djangoapps/mobile_api/middleware.py new file mode 100644 index 0000000000..921cb2f8ab --- /dev/null +++ b/lms/djangoapps/mobile_api/middleware.py @@ -0,0 +1,133 @@ +""" +Middleware for Mobile APIs +""" +from datetime import datetime +from django.conf import settings +from django.core.cache import cache +from django.http import HttpResponse +from pytz import UTC +from mobile_api.mobile_platform import MobilePlatform +from mobile_api.models import AppVersionConfig +from mobile_api.utils import parsed_version +from openedx.core.lib.mobile_utils import is_request_from_mobile_app +import request_cache + + +class AppVersionUpgrade(object): + """ + Middleware class to keep track of mobile application version being used. + """ + LATEST_VERSION_HEADER = 'EDX-APP-LATEST-VERSION' + LAST_SUPPORTED_DATE_HEADER = 'EDX-APP-VERSION-LAST-SUPPORTED-DATE' + NO_LAST_SUPPORTED_DATE = 'NO_LAST_SUPPORTED_DATE' + NO_LATEST_VERSION = 'NO_LATEST_VERSION' + USER_APP_VERSION = 'USER_APP_VERSION' + REQUEST_CACHE_NAME = 'app-version-info' + CACHE_TIMEOUT = settings.APP_UPGRADE_CACHE_TIMEOUT + + def process_request(self, request): + """ + Processes request to validate app version that is making request. + + Returns: + Http response with status code 426 (i.e. Update Required) if request is from + mobile native app and app version is no longer supported else returns None + """ + version_data = self._get_version_info(request) + if version_data: + last_supported_date = version_data[self.LAST_SUPPORTED_DATE_HEADER] + if last_supported_date != self.NO_LAST_SUPPORTED_DATE: + if datetime.now().replace(tzinfo=UTC) > last_supported_date: + return HttpResponse(status=426) # Http status 426; Update Required + + def process_response(self, __, response): + """ + If request is from mobile native app, then add version related info to response headers. + + Returns: + Http response: with additional headers; + 1. EDX-APP-LATEST-VERSION; if user app version < latest available version + 2. EDX-APP-VERSION-LAST-SUPPORTED-DATE; if user app version < min supported version and + timestamp < expiry of that version + """ + request_cache_dict = request_cache.get_cache(self.REQUEST_CACHE_NAME) + if request_cache_dict: + last_supported_date = request_cache_dict[self.LAST_SUPPORTED_DATE_HEADER] + if last_supported_date != self.NO_LAST_SUPPORTED_DATE: + response[self.LAST_SUPPORTED_DATE_HEADER] = last_supported_date.isoformat() + latest_version = request_cache_dict[self.LATEST_VERSION_HEADER] + user_app_version = request_cache_dict[self.USER_APP_VERSION] + if (latest_version != self.NO_LATEST_VERSION and + parsed_version(user_app_version) < parsed_version(latest_version)): + response[self.LATEST_VERSION_HEADER] = latest_version + return response + + def _get_cache_key_name(self, field, key): + """ + Get key name to use to cache any property against field name and identification key. + + Arguments: + field (str): The property name that needs to get cached. + key (str): Unique identification for cache key (e.g. platform_name). + + Returns: + string: Cache key to be used. + """ + return "mobile_api.app_version_upgrade.{}.{}".format(field, key) + + def _get_version_info(self, request): + """ + Gets and Sets version related info in mem cache and request cache; and returns a dict of it. + + It sets request cache data for last_supported_date and latest_version with memcached values if exists against + user app properties else computes the values for specific platform and sets it in both memcache (for next + server interaction from same app version/platform) and request cache + + Returns: + dict: Containing app version info + """ + user_agent = request.META.get('HTTP_USER_AGENT') + if user_agent: + platform = self._get_platform(request, user_agent) + if platform: + request_cache_dict = request_cache.get_cache(self.REQUEST_CACHE_NAME) + request_cache_dict[self.USER_APP_VERSION] = platform.version + last_supported_date_cache_key = self._get_cache_key_name( + self.LAST_SUPPORTED_DATE_HEADER, + platform.version + ) + latest_version_cache_key = self._get_cache_key_name(self.LATEST_VERSION_HEADER, platform.NAME) + cached_data = cache.get_many([last_supported_date_cache_key, latest_version_cache_key]) + + last_supported_date = cached_data.get(last_supported_date_cache_key) + if not last_supported_date: + last_supported_date = self._get_last_supported_date(platform.NAME, platform.version) + cache.set(last_supported_date_cache_key, last_supported_date, self.CACHE_TIMEOUT) + request_cache_dict[self.LAST_SUPPORTED_DATE_HEADER] = last_supported_date + + latest_version = cached_data.get(latest_version_cache_key) + if not latest_version: + latest_version = self._get_latest_version(platform.NAME) + cache.set(latest_version_cache_key, latest_version, self.CACHE_TIMEOUT) + request_cache_dict[self.LATEST_VERSION_HEADER] = latest_version + + return request_cache_dict + + def _get_platform(self, request, user_agent): + """ + Determines the platform type for mobile app making the request against user_agent. + + Returns: + None if request app does not belong to one of the supported mobile platforms + else returns an instance of corresponding mobile platform. + """ + if is_request_from_mobile_app(request): + return MobilePlatform.get_instance(user_agent) + + def _get_last_supported_date(self, platform_name, platform_version): + """ Get expiry date of app version for a platform. """ + return AppVersionConfig.last_supported_date(platform_name, platform_version) or self.NO_LAST_SUPPORTED_DATE + + def _get_latest_version(self, platform_name): + """ Get latest app version available for platform. """ + return AppVersionConfig.latest_version(platform_name) or self.NO_LATEST_VERSION diff --git a/lms/djangoapps/mobile_api/migrations/0002_auto_20160406_0904.py b/lms/djangoapps/mobile_api/migrations/0002_auto_20160406_0904.py new file mode 100644 index 0000000000..0aeac1331e --- /dev/null +++ b/lms/djangoapps/mobile_api/migrations/0002_auto_20160406_0904.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mobile_api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AppVersionConfig', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('platform', models.CharField(max_length=50, choices=[(b'Android', b'Android'), (b'iOS', b'iOS')])), + ('version', models.CharField(help_text=b'Version should be in the format X.X.X.Y where X is a number and Y is alphanumeric', max_length=50)), + ('major_version', models.IntegerField()), + ('minor_version', models.IntegerField()), + ('patch_version', models.IntegerField()), + ('expire_at', models.DateTimeField(null=True, verbose_name=b'Expiry date for platform version', blank=True)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-major_version', '-minor_version', '-patch_version'], + }, + ), + migrations.AlterUniqueTogether( + name='appversionconfig', + unique_together=set([('platform', 'version')]), + ), + ] diff --git a/lms/djangoapps/mobile_api/mobile_platform.py b/lms/djangoapps/mobile_api/mobile_platform.py new file mode 100644 index 0000000000..554efe8049 --- /dev/null +++ b/lms/djangoapps/mobile_api/mobile_platform.py @@ -0,0 +1,69 @@ +""" +Platform related Operations for Mobile APP +""" +import abc +import re + + +class MobilePlatform: + """ + MobilePlatform class creates an instance of platform based on user agent and supports platform + related operations. + """ + __metaclass__ = abc.ABCMeta + version = None + + def __init__(self, version): + self.version = version + + @classmethod + def get_user_app_platform(cls, user_agent, user_agent_regex): + """ + Returns platform instance if user_agent matches with USER_AGENT_REGEX + + Arguments: + user_agent (str): user-agent for mobile app making the request. + user_agent_regex (regex str): Regex for user-agent valid for any type pf mobile platform. + + Returns: + An instance of class passed (which would be one of the supported mobile platform + classes i.e. PLATFORM_CLASSES) if user_agent matches regex of that class else returns None + """ + match = re.search(user_agent_regex, user_agent) + if match: + return cls(match.group('version')) + + @classmethod + def get_instance(cls, user_agent): + """ + It creates an instance of one of the supported mobile platforms (i.e. iOS, Android) by regex comparison + of user-agent. + + Parameters: + user_agent: user_agent of mobile app + + Returns: + instance of one of the supported mobile platforms (i.e. iOS, Android) + """ + for subclass in PLATFORM_CLASSES.values(): + instance = subclass.get_user_app_platform(user_agent, subclass.USER_AGENT_REGEX) + if instance: + return instance + + +class IOS(MobilePlatform): + """ iOS platform """ + USER_AGENT_REGEX = (r'\((?P[0-9]+.[0-9]+.[0-9]+(.[0-9a-zA-Z]*)?); OS Version [0-9.]+ ' + r'\(Build [0-9a-zA-Z]*\)\)') + NAME = "iOS" + + +class Android(MobilePlatform): + """ Android platform """ + USER_AGENT_REGEX = (r'Dalvik/[.0-9]+ \(Linux; U; Android [.0-9]+; (.*) Build/[0-9a-zA-Z]*\) ' + r'(.*)/(?P[0-9]+.[0-9]+.[0-9]+(.[0-9a-zA-Z]*)?)') + NAME = "Android" + + +# a list of all supported mobile platforms +PLATFORM_CLASSES = {IOS.NAME: IOS, Android.NAME: Android} diff --git a/lms/djangoapps/mobile_api/models.py b/lms/djangoapps/mobile_api/models.py index 6413b3eded..2d30a9ce91 100644 --- a/lms/djangoapps/mobile_api/models.py +++ b/lms/djangoapps/mobile_api/models.py @@ -1,10 +1,10 @@ """ ConfigurationModel for the mobile_api djangoapp. """ - -from django.db.models.fields import TextField - +from django.db import models +from mobile_api import utils from config_models.models import ConfigurationModel +from mobile_api.mobile_platform import PLATFORM_CLASSES class MobileApiConfig(ConfigurationModel): @@ -14,7 +14,7 @@ class MobileApiConfig(ConfigurationModel): The order in which the comma-separated list of names of profiles are given is in priority order. """ - video_profiles = TextField( + video_profiles = models.TextField( blank=True, help_text="A comma-separated list of names of profiles to include for videos returned from the mobile API." ) @@ -25,3 +25,54 @@ class MobileApiConfig(ConfigurationModel): Get the list of profiles in priority order when requesting from VAL """ return [profile.strip() for profile in cls.current().video_profiles.split(",") if profile] + + +class AppVersionConfig(models.Model): + """ + Configuration for mobile app versions available. + """ + PLATFORM_CHOICES = tuple([ + (platform, platform) + for platform in PLATFORM_CLASSES.keys() + ]) + platform = models.CharField(max_length=50, choices=PLATFORM_CHOICES, blank=False) + version = models.CharField( + max_length=50, + blank=False, + help_text="Version should be in the format X.X.X.Y where X is a number and Y is alphanumeric" + ) + major_version = models.IntegerField() + minor_version = models.IntegerField() + patch_version = models.IntegerField() + expire_at = models.DateTimeField(null=True, blank=True, verbose_name="Expiry date for platform version") + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('platform', 'version',) + ordering = ['-major_version', '-minor_version', '-patch_version'] + + def __unicode__(self): + return "{}_{}".format(self.platform, self.version) + + @classmethod + def latest_version(cls, platform): + """ Returns latest supported app version for a platform. """ + latest_version_config = cls.objects.filter(platform=platform, enabled=True).first() + if latest_version_config: + return latest_version_config.version + + @classmethod + def last_supported_date(cls, platform, version): + """ Returns date when app version will get expired for a platform """ + parsed_version = utils.parsed_version(version) + active_configs = cls.objects.filter(platform=platform, enabled=True, expire_at__isnull=False).reverse() + for config in active_configs: + if utils.parsed_version(config.version) >= parsed_version: + return config.expire_at + + def save(self, *args, **kwargs): + """ parses version into major, minor and patch versions before saving """ + self.major_version, self.minor_version, self.patch_version = utils.parsed_version(self.version) + super(AppVersionConfig, self).save(*args, **kwargs) diff --git a/lms/djangoapps/mobile_api/tests.py b/lms/djangoapps/mobile_api/tests.py deleted file mode 100644 index 88c9368ba2..0000000000 --- a/lms/djangoapps/mobile_api/tests.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Tests for mobile API utilities. -""" - -import ddt -from django.test import TestCase -from mobile_api.models import MobileApiConfig - -from .utils import mobile_course_access, mobile_view - - -@ddt.ddt -class TestMobileAPIDecorators(TestCase): - """ - Basic tests for mobile api decorators to ensure they retain the docstrings. - """ - @ddt.data(mobile_view, mobile_course_access) - def test_function_decorator(self, decorator): - @decorator() - def decorated_func(): - """ - Test docstring of decorated function. - """ - pass - - self.assertIn("Test docstring of decorated function.", decorated_func.__doc__) - self.assertEquals(decorated_func.__name__, "decorated_func") - self.assertTrue(decorated_func.__module__.endswith("tests")) - - -class TestMobileApiConfig(TestCase): - """ - Tests MobileAPIConfig - """ - - def test_video_profile_list(self): - """Check that video_profiles config is returned in order as a list""" - MobileApiConfig(video_profiles="mobile_low,mobile_high,youtube").save() - video_profile_list = MobileApiConfig.get_video_profiles() - self.assertEqual( - video_profile_list, - [u'mobile_low', u'mobile_high', u'youtube'] - ) - - def test_video_profile_list_with_whitespace(self): - """Check video_profiles config with leading and trailing whitespace""" - MobileApiConfig(video_profiles=" mobile_low , mobile_high,youtube ").save() - video_profile_list = MobileApiConfig.get_video_profiles() - self.assertEqual( - video_profile_list, - [u'mobile_low', u'mobile_high', u'youtube'] - ) - - def test_empty_video_profile(self): - """Test an empty video_profile""" - MobileApiConfig(video_profiles="").save() - video_profile_list = MobileApiConfig.get_video_profiles() - self.assertEqual(video_profile_list, []) diff --git a/lms/djangoapps/mobile_api/tests/__init__.py b/lms/djangoapps/mobile_api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/mobile_api/tests/test_decorator.py b/lms/djangoapps/mobile_api/tests/test_decorator.py new file mode 100644 index 0000000000..5b30f72a50 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_decorator.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +Tests for mobile API utilities. +""" + +import ddt +from django.test import TestCase + +from mobile_api.utils import mobile_course_access, mobile_view + + +@ddt.ddt +class TestMobileAPIDecorators(TestCase): + """ + Basic tests for mobile api decorators to ensure they retain the docstrings. + """ + @ddt.data(mobile_view, mobile_course_access) + def test_function_decorator(self, decorator): + @decorator() + def decorated_func(): + """ + Test docstring of decorated function. + """ + pass + + self.assertIn("Test docstring of decorated function.", decorated_func.__doc__) + self.assertEquals(decorated_func.__name__, "decorated_func") + self.assertTrue(decorated_func.__module__.endswith("test_decorator")) diff --git a/lms/djangoapps/mobile_api/tests/test_middleware.py b/lms/djangoapps/mobile_api/tests/test_middleware.py new file mode 100644 index 0000000000..7ffbbdce50 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_middleware.py @@ -0,0 +1,156 @@ +""" +Tests for Version Based App Upgrade Middleware +""" +from datetime import datetime +import ddt +from django.core.cache import cache, caches +from django.http import HttpRequest, HttpResponse +from django.test import TestCase +import mock +from pytz import UTC +from mobile_api.middleware import AppVersionUpgrade +from mobile_api.models import AppVersionConfig + + +@ddt.ddt +class TestAppVersionUpgradeMiddleware(TestCase): + """ + Tests for version based app upgrade middleware + """ + def setUp(self): + super(TestAppVersionUpgradeMiddleware, self).setUp() + self.middleware = AppVersionUpgrade() + self.set_app_version_config() + cache.clear() + + def set_app_version_config(self): + """ Creates configuration data for platform versions """ + AppVersionConfig(platform="iOS", version="1.1.1", expire_at=None, enabled=True).save() + AppVersionConfig( + platform="iOS", + version="2.2.2", + expire_at=datetime(2014, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig( + platform="iOS", + version="4.4.4", + expire_at=datetime(9000, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig(platform="iOS", version="6.6.6", expire_at=None, enabled=True).save() + + AppVersionConfig(platform="Android", version="1.1.1", expire_at=None, enabled=True).save() + AppVersionConfig( + platform="Android", + version="2.2.2", + expire_at=datetime(2014, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig( + platform="Android", + version="4.4.4", + expire_at=datetime(5000, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig(platform="Android", version="8.8.8", expire_at=None, enabled=True).save() + + def process_middleware(self, user_agent, cache_get_many_calls_for_request=1): + """ Helper function that makes calls to middle process_request and process_response """ + fake_request = HttpRequest() + fake_request.META['HTTP_USER_AGENT'] = user_agent + with mock.patch.object(caches['default'], 'get_many', wraps=caches['default'].get_many) as mocked_code: + request_response = self.middleware.process_request(fake_request) + self.assertEqual(cache_get_many_calls_for_request, mocked_code.call_count) + with mock.patch.object(caches['default'], 'get_many', wraps=caches['default'].get_many) as mocked_code: + processed_response = self.middleware.process_response(fake_request, request_response or HttpResponse()) + self.assertEqual(0, mocked_code.call_count) + return request_response, processed_response + + @ddt.data( + ("Mozilla/5.0 (Linux; Android 5.1; Nexus 5 Build/LMY47I; wv) AppleWebKit/537.36 (KHTML, like Gecko) " + "Version/4.0 Chrome/47.0.2526.100 Mobile Safari/537.36 edX/org.edx.mobile/2.0.0"), + ("Mozilla/5.0 (iPhone; CPU iPhone OS 9_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) " + "Mobile/13C75 edX/org.edx.mobile/2.2.1"), + ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 " + "Safari/537.36"), + ) + def test_non_mobile_app_requests(self, user_agent): + with self.assertNumQueries(0): + request_response, processed_response = self.process_middleware(user_agent, 0) + self.assertIsNone(request_response) + self.assertEquals(200, processed_response.status_code) + self.assertNotIn(AppVersionUpgrade.LATEST_VERSION_HEADER, processed_response) + self.assertNotIn(AppVersionUpgrade.LAST_SUPPORTED_DATE_HEADER, processed_response) + + @ddt.data( + "edX/org.edx.mobile (6.6.6; OS Version 9.2 (Build 13C75))", + "edX/org.edx.mobile (7.7.7; OS Version 9.2 (Build 13C75))", + "Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/8.8.8", + "Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/9.9.9", + ) + def test_no_update(self, user_agent): + with self.assertNumQueries(2): + request_response, processed_response = self.process_middleware(user_agent) + self.assertIsNone(request_response) + self.assertEquals(200, processed_response.status_code) + self.assertNotIn(AppVersionUpgrade.LATEST_VERSION_HEADER, processed_response) + self.assertNotIn(AppVersionUpgrade.LAST_SUPPORTED_DATE_HEADER, processed_response) + with self.assertNumQueries(0): + self.process_middleware(user_agent) + + @ddt.data( + ("edX/org.edx.mobile (5.1.1; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("edX/org.edx.mobile (5.1.1.RC; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/5.1.1", "8.8.8"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/5.1.1.RC", "8.8.8"), + ) + @ddt.unpack + def test_new_version_available(self, user_agent, latest_version): + with self.assertNumQueries(2): + request_response, processed_response = self.process_middleware(user_agent) + self.assertIsNone(request_response) + self.assertEquals(200, processed_response.status_code) + self.assertEqual(latest_version, processed_response[AppVersionUpgrade.LATEST_VERSION_HEADER]) + self.assertNotIn(AppVersionUpgrade.LAST_SUPPORTED_DATE_HEADER, processed_response) + with self.assertNumQueries(0): + self.process_middleware(user_agent) + + @ddt.data( + ("edX/org.edx.mobile (1.0.1; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("edX/org.edx.mobile (1.1.1; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("edX/org.edx.mobile (2.0.5.RC; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("edX/org.edx.mobile (2.2.2; OS Version 9.2 (Build 13C75))", "6.6.6"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/1.0.1", "8.8.8"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/1.1.1", "8.8.8"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/2.0.5.RC", "8.8.8"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/2.2.2", "8.8.8"), + ) + @ddt.unpack + def test_version_update_required(self, user_agent, latest_version): + with self.assertNumQueries(2): + request_response, processed_response = self.process_middleware(user_agent) + self.assertIsNotNone(request_response) + self.assertEquals(426, processed_response.status_code) + self.assertEqual(latest_version, processed_response[AppVersionUpgrade.LATEST_VERSION_HEADER]) + with self.assertNumQueries(0): + self.process_middleware(user_agent) + + @ddt.data( + ("edX/org.edx.mobile (4.4.4; OS Version 9.2 (Build 13C75))", "6.6.6", '9000-01-01T00:00:00+00:00'), + ( + "Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/4.4.4", + "8.8.8", + '5000-01-01T00:00:00+00:00', + ), + ) + @ddt.unpack + def test_version_update_available_with_deadline(self, user_agent, latest_version, upgrade_date): + with self.assertNumQueries(2): + request_response, processed_response = self.process_middleware(user_agent) + self.assertIsNone(request_response) + self.assertEquals(200, processed_response.status_code) + self.assertEqual(latest_version, processed_response[AppVersionUpgrade.LATEST_VERSION_HEADER]) + self.assertEqual(upgrade_date, processed_response[AppVersionUpgrade.LAST_SUPPORTED_DATE_HEADER]) + with self.assertNumQueries(0): + self.process_middleware(user_agent) diff --git a/lms/djangoapps/mobile_api/test_milestones.py b/lms/djangoapps/mobile_api/tests/test_milestones.py similarity index 100% rename from lms/djangoapps/mobile_api/test_milestones.py rename to lms/djangoapps/mobile_api/tests/test_milestones.py diff --git a/lms/djangoapps/mobile_api/tests/test_mobile_platform.py b/lms/djangoapps/mobile_api/tests/test_mobile_platform.py new file mode 100644 index 0000000000..e64943be19 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_mobile_platform.py @@ -0,0 +1,46 @@ +""" +Tests for Platform against Mobile App Request +""" +import ddt +from django.test import TestCase +from mobile_api.mobile_platform import MobilePlatform + + +@ddt.ddt +class TestMobilePlatform(TestCase): + """ + Tests for platform against mobile app request + """ + def setUp(self): + super(TestMobilePlatform, self).setUp() + + @ddt.data( + ("edX/org.edx.mobile (0.1.5; OS Version 9.2 (Build 13C75))", "iOS", "0.1.5"), + ("edX/org.edx.mobile (1.01.1; OS Version 9.2 (Build 13C75))", "iOS", "1.01.1"), + ("edX/org.edx.mobile (2.2.2; OS Version 9.2 (Build 13C75))", "iOS", "2.2.2"), + ("edX/org.edx.mobile (3.3.3; OS Version 9.2 (Build 13C75))", "iOS", "3.3.3"), + ("edX/org.edx.mobile (3.3.3.test; OS Version 9.2 (Build 13C75))", "iOS", "3.3.3.test"), + ("edX/org.test-domain.mobile (0.1.5; OS Version 9.2 (Build 13C75))", "iOS", "0.1.5"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/1.1.1", "Android", "1.1.1"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/2.2.2", "Android", "2.2.2"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/3.3.3", "Android", "3.3.3"), + ("Dalvik/2.1.0 (Linux; U; Android 5.1; Nexus 5 Build/LMY47I) edX/org.edx.mobile/3.3.3.X", "Android", "3.3.3.X"), + ) + @ddt.unpack + def test_platform_instance(self, user_agent, platform_name, version): + platform = MobilePlatform.get_instance(user_agent) + self.assertEqual(platform_name, platform.NAME) + self.assertEqual(version, platform.version) + + @ddt.data( + ("Mozilla/5.0 (Linux; Android 5.1; Nexus 5 Build/LMY47I; wv) AppleWebKit/537.36 (KHTML, like Gecko) " + "Version/4.0 Chrome/47.0.2526.100 Mobile Safari/537.36 edX/org.edx.mobile/2.0.0"), + ("Mozilla/5.0 (iPhone; CPU iPhone OS 9_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) " + "Mobile/13C75 edX/org.edx.mobile/2.2.1"), + ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 " + "Safari/537.36"), + "edX/org.edx.mobile (0.1.5.2.; OS Version 9.2 (Build 13C75))", + "edX/org.edx.mobile (0.1.5.2.5.1; OS Version 9.2 (Build 13C75))", + ) + def test_non_mobile_app_requests(self, user_agent): + self.assertIsNone(MobilePlatform.get_instance(user_agent)) diff --git a/lms/djangoapps/mobile_api/tests/test_model.py b/lms/djangoapps/mobile_api/tests/test_model.py new file mode 100644 index 0000000000..c361736fe3 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_model.py @@ -0,0 +1,115 @@ +""" +Tests for Mobile API Configuration Models +""" +from datetime import datetime +import ddt +from django.test import TestCase +from pytz import UTC +from mobile_api.models import AppVersionConfig, MobileApiConfig + + +@ddt.ddt +class TestAppVersionConfigModel(TestCase): + """ + Tests for app version configuration model + """ + def setUp(self): + super(TestAppVersionConfigModel, self).setUp() + + def set_app_version_config(self): + """ Creates configuration data for platform versions """ + AppVersionConfig(platform="ios", version="1.1.1", expire_at=None, enabled=True).save() + AppVersionConfig( + platform="ios", + version="2.2.2", + expire_at=datetime(2014, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig( + platform="ios", + version="4.1.1", + expire_at=datetime(5000, 01, 01, tzinfo=UTC), + enabled=False + ).save() + AppVersionConfig( + platform="ios", + version="4.4.4", + expire_at=datetime(9000, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig(platform="ios", version="6.6.6", expire_at=None, enabled=True).save() + AppVersionConfig(platform="ios", version="8.8.8", expire_at=None, enabled=False).save() + + AppVersionConfig(platform="android", version="1.1.1", expire_at=None, enabled=True).save() + AppVersionConfig( + platform="android", + version="2.2.2", + expire_at=datetime(2014, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig( + platform="android", + version="4.4.4", + expire_at=datetime(9000, 01, 01, tzinfo=UTC), + enabled=True + ).save() + AppVersionConfig(platform="android", version="8.8.8", expire_at=None, enabled=True).save() + + @ddt.data( + ('ios', '4.4.4'), + ('ios', '6.6.6'), + ("android", '4.4.4'), + ('android', '8.8.8') + ) + @ddt.unpack + def test_no_configs_available(self, platform, version): + self.assertIsNone(AppVersionConfig.latest_version(platform)) + self.assertIsNone(AppVersionConfig.last_supported_date(platform, version)) + + @ddt.data(('ios', '6.6.6'), ('android', '8.8.8')) + @ddt.unpack + def test_latest_version(self, platform, latest_version): + self.set_app_version_config() + self.assertEqual(latest_version, AppVersionConfig.latest_version(platform)) + + @ddt.data( + ('ios', '3.3.3', datetime(9000, 1, 1, tzinfo=UTC)), + ('ios', '4.4.4', datetime(9000, 1, 1, tzinfo=UTC)), + ('ios', '6.6.6', None), + ("android", '4.4.4', datetime(9000, 1, 1, tzinfo=UTC)), + ('android', '8.8.8', None) + ) + @ddt.unpack + def test_last_supported_date(self, platform, version, last_supported_date): + self.set_app_version_config() + self.assertEqual(last_supported_date, AppVersionConfig.last_supported_date(platform, version)) + + +class TestMobileApiConfig(TestCase): + """ + Tests MobileAPIConfig + """ + + def test_video_profile_list(self): + """Check that video_profiles config is returned in order as a list""" + MobileApiConfig(video_profiles="mobile_low,mobile_high,youtube").save() + video_profile_list = MobileApiConfig.get_video_profiles() + self.assertEqual( + video_profile_list, + [u'mobile_low', u'mobile_high', u'youtube'] + ) + + def test_video_profile_list_with_whitespace(self): + """Check video_profiles config with leading and trailing whitespace""" + MobileApiConfig(video_profiles=" mobile_low , mobile_high,youtube ").save() + video_profile_list = MobileApiConfig.get_video_profiles() + self.assertEqual( + video_profile_list, + [u'mobile_low', u'mobile_high', u'youtube'] + ) + + def test_empty_video_profile(self): + """Test an empty video_profile""" + MobileApiConfig(video_profiles="").save() + video_profile_list = MobileApiConfig.get_video_profiles() + self.assertEqual(video_profile_list, []) diff --git a/lms/djangoapps/mobile_api/testutils.py b/lms/djangoapps/mobile_api/testutils.py index 93d4f47d1d..1f1c93ad5d 100644 --- a/lms/djangoapps/mobile_api/testutils.py +++ b/lms/djangoapps/mobile_api/testutils.py @@ -10,16 +10,16 @@ Test utilities for mobile API tests: MobileCourseAccessTestMixin - tests for APIs with mobile_course_access. """ # pylint: disable=no-member -import ddt from datetime import timedelta + from django.utils import timezone +import ddt from mock import patch - from django.core.urlresolvers import reverse - from rest_framework.test import APITestCase - from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from courseware.access_response import ( MobileAvailabilityError, @@ -29,10 +29,7 @@ from courseware.access_response import ( from courseware.tests.factories import UserFactory from student import auth from student.models import CourseEnrollment -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - -from mobile_api.test_milestones import MobileAPIMilestonesMixin +from mobile_api.tests.test_milestones import MobileAPIMilestonesMixin class MobileAPITestCase(ModuleStoreTestCase, APITestCase): diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 671872b7ca..d9b5f6c250 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -3,15 +3,18 @@ Tests for users API """ # pylint: disable=no-member import datetime + import ddt from mock import patch from nose.plugins.attrib import attr import pytz - from django.conf import settings from django.utils import timezone from django.template import defaultfilters from django.test import RequestFactory +from milestones.tests.utils import MilestonesTestCaseMixin +from xmodule.course_module import DEFAULT_START_DATE +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from certificates.api import generate_user_certificates from certificates.models import CertificateStatuses @@ -25,13 +28,14 @@ from course_modes.models import CourseMode from openedx.core.lib.courses import course_image_url from student.models import CourseEnrollment from util.milestones_helpers import set_prerequisite_courses -from milestones.tests.utils import MilestonesTestCaseMixin -from xmodule.course_module import DEFAULT_START_DATE -from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from util.testing import UrlResetMixin - from .. import errors -from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileAuthUserTestMixin, MobileCourseAccessTestMixin +from mobile_api.testutils import ( + MobileAPITestCase, + MobileAuthTestMixin, + MobileAuthUserTestMixin, + MobileCourseAccessTestMixin, +) from .serializers import CourseEnrollmentSerializer diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 2ef7270561..ac89cb9f2d 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -16,3 +16,8 @@ def mobile_view(is_user=False): Function and class decorator that abstracts the authentication and permission checks for mobile api views. """ return view_auth_classes(is_user) + + +def parsed_version(version): + """ Converts string X.X.X.Y to int tuple (X, X, X) """ + return tuple(map(int, (version.split(".")[:3]))) diff --git a/lms/djangoapps/mobile_api/video_outlines/tests.py b/lms/djangoapps/mobile_api/video_outlines/tests.py index 9a0217d5d3..92b6c13b2e 100644 --- a/lms/djangoapps/mobile_api/video_outlines/tests.py +++ b/lms/djangoapps/mobile_api/video_outlines/tests.py @@ -3,26 +3,24 @@ Tests for video outline API """ -import ddt import itertools -from nose.plugins.attrib import attr from uuid import uuid4 from collections import namedtuple +import ddt +from nose.plugins.attrib import attr from edxval import api -from mobile_api.models import MobileApiConfig from xmodule.modulestore.tests.factories import ItemFactory from xmodule.video_module import transcripts_utils from xmodule.modulestore.django import modulestore from xmodule.partitions.partitions import Group, UserPartition +from milestones.tests.utils import MilestonesTestCaseMixin +from mobile_api.models import MobileApiConfig from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, remove_user_from_cohort - -from milestones.tests.utils import MilestonesTestCaseMixin - -from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin +from mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin class TestVideoAPITestCase(MobileAPITestCase): diff --git a/lms/envs/aws.py b/lms/envs/aws.py index c5ee1bd76c..b81ba6772c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -802,3 +802,6 @@ if FEATURES.get('ENABLE_CSMH_EXTENDED'): INSTALLED_APPS += ('coursewarehistoryextended',) API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL') + +# Mobile App Version Upgrade config +APP_UPGRADE_CACHE_TIMEOUT = ENV_TOKENS.get('APP_UPGRADE_CACHE_TIMEOUT', APP_UPGRADE_CACHE_TIMEOUT) diff --git a/lms/envs/common.py b/lms/envs/common.py index 549ea11d62..6ad37a6ca9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -29,7 +29,6 @@ Longer TODO: # and throws spurious errors. Therefore, we disable invalid-name checking. # pylint: disable=invalid-name -import datetime import imp import sys import os @@ -1090,6 +1089,7 @@ simplefilter('ignore') MIDDLEWARE_CLASSES = ( 'request_cache.middleware.RequestCache', + 'mobile_api.middleware.AppVersionUpgrade', 'header_control.middleware.HeaderControlMiddleware', 'microsite_configuration.middleware.MicrositeMiddleware', 'django_comment_client.middleware.AjaxExceptionMiddleware', @@ -2850,6 +2850,9 @@ MOBILE_APP_USER_AGENT_REGEXES = [ r'edX/org.edx.mobile', ] +# cache timeout in seconds for Mobile App Version Upgrade +APP_UPGRADE_CACHE_TIMEOUT = 3600 + # Offset for courseware.StudentModuleHistoryExtended which is used to # calculate the starting primary key for the underlying table. This gap # should be large enough that you do not generate more than N courseware.StudentModuleHistory diff --git a/openedx/core/lib/mobile_utils.py b/openedx/core/lib/mobile_utils.py index 0db979c023..8c90e6a8b1 100644 --- a/openedx/core/lib/mobile_utils.py +++ b/openedx/core/lib/mobile_utils.py @@ -29,7 +29,7 @@ def is_request_from_mobile_app(request): user_agent = request.META.get('HTTP_USER_AGENT') if user_agent: for user_agent_regex in settings.MOBILE_APP_USER_AGENT_REGEXES: - if re.match(user_agent_regex, user_agent): + if re.search(user_agent_regex, user_agent): return True return False